@aquera/nile-elements 2.0.19 → 2.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/demo/index.js +0 -1
- package/dist/index.cjs.js +1 -1
- package/dist/index.esm.js +1 -1
- package/dist/index.js +1254 -488
- package/dist/nile-badge/index.cjs.js +1 -1
- package/dist/nile-badge/index.esm.js +1 -1
- package/dist/nile-badge/nile-badge.cjs.js +1 -1
- package/dist/nile-badge/nile-badge.cjs.js.map +1 -1
- package/dist/nile-badge/nile-badge.css.cjs.js +1 -1
- package/dist/nile-badge/nile-badge.css.cjs.js.map +1 -1
- package/dist/nile-badge/nile-badge.css.esm.js +24 -3
- package/dist/nile-badge/nile-badge.esm.js +5 -3
- package/dist/nile-calendar/index.cjs.js +1 -1
- package/dist/nile-calendar/index.esm.js +1 -1
- package/dist/nile-calendar/nile-calendar.cjs.js +2 -1
- package/dist/nile-calendar/nile-calendar.cjs.js.map +1 -1
- package/dist/nile-calendar/nile-calendar.css.cjs.js +1 -1
- package/dist/nile-calendar/nile-calendar.css.cjs.js.map +1 -1
- package/dist/nile-calendar/nile-calendar.css.esm.js +357 -2
- package/dist/nile-calendar/nile-calendar.esm.js +365 -41
- package/dist/nile-calendar/timezones.cjs.js +1 -1
- package/dist/nile-calendar/timezones.cjs.js.map +1 -1
- package/dist/nile-calendar/timezones.esm.js +1 -1
- package/dist/nile-date-time-picker/index.cjs.js +2 -0
- package/dist/nile-date-time-picker/index.cjs.js.map +1 -0
- package/dist/nile-date-time-picker/index.esm.js +1 -0
- package/dist/nile-date-time-picker/nile-date-time-picker.cjs.js +2 -0
- package/dist/nile-date-time-picker/nile-date-time-picker.cjs.js.map +1 -0
- package/dist/nile-date-time-picker/nile-date-time-picker.css.cjs.js +2 -0
- package/dist/nile-date-time-picker/nile-date-time-picker.css.cjs.js.map +1 -0
- package/dist/nile-date-time-picker/nile-date-time-picker.css.esm.js +13 -0
- package/dist/nile-date-time-picker/nile-date-time-picker.esm.js +53 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/nile-badge/nile-badge.css.js +24 -3
- package/dist/src/nile-badge/nile-badge.css.js.map +1 -1
- package/dist/src/nile-badge/nile-badge.d.ts +7 -4
- package/dist/src/nile-badge/nile-badge.js +22 -5
- package/dist/src/nile-badge/nile-badge.js.map +1 -1
- package/dist/src/nile-badge/nile-badge.test.js +88 -0
- package/dist/src/nile-badge/nile-badge.test.js.map +1 -1
- package/dist/src/nile-calendar/nile-calendar.css.js +355 -0
- package/dist/src/nile-calendar/nile-calendar.css.js.map +1 -1
- package/dist/src/nile-calendar/nile-calendar.d.ts +175 -0
- package/dist/src/nile-calendar/nile-calendar.js +1726 -26
- package/dist/src/nile-calendar/nile-calendar.js.map +1 -1
- package/dist/src/nile-calendar/nile-calendar.test.d.ts +1 -0
- package/dist/src/nile-calendar/nile-calendar.test.js +3580 -0
- package/dist/src/nile-calendar/nile-calendar.test.js.map +1 -1
- package/dist/src/nile-calendar/timezones.d.ts +21 -0
- package/dist/src/nile-calendar/timezones.js +49 -0
- package/dist/src/nile-calendar/timezones.js.map +1 -1
- package/dist/src/nile-date-time-picker/index.d.ts +1 -0
- package/dist/src/nile-date-time-picker/index.js +2 -0
- package/dist/src/nile-date-time-picker/index.js.map +1 -0
- package/dist/src/nile-date-time-picker/nile-date-time-picker.css.d.ts +11 -0
- package/dist/src/nile-date-time-picker/nile-date-time-picker.css.js +49 -0
- package/dist/src/nile-date-time-picker/nile-date-time-picker.css.js.map +1 -0
- package/dist/src/nile-date-time-picker/nile-date-time-picker.d.ts +173 -0
- package/dist/src/nile-date-time-picker/nile-date-time-picker.js +1270 -0
- package/dist/src/nile-date-time-picker/nile-date-time-picker.js.map +1 -0
- package/dist/src/nile-date-time-picker/nile-date-time-picker.test.d.ts +143 -0
- package/dist/src/nile-date-time-picker/nile-date-time-picker.test.js +7277 -0
- package/dist/src/nile-date-time-picker/nile-date-time-picker.test.js.map +1 -0
- package/dist/src/version.js +1 -1
- package/dist/src/version.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/utilities/animation-registry.cjs.js +1 -1
- package/dist/utilities/animation-registry.cjs.js.map +1 -1
- package/dist/utilities/animation-registry.esm.js +1 -1
- package/package.json +3 -1
- package/scripts/coverage-report.mjs +96 -0
- package/src/index.ts +1 -0
- package/src/nile-badge/nile-badge.css.ts +24 -3
- package/src/nile-badge/nile-badge.test.ts +102 -0
- package/src/nile-badge/nile-badge.ts +28 -5
- package/src/nile-calendar/nile-calendar.css.ts +355 -0
- package/src/nile-calendar/nile-calendar.test.ts +3793 -0
- package/src/nile-calendar/nile-calendar.ts +1760 -28
- package/src/nile-calendar/timezones.ts +57 -0
- package/src/nile-date-time-picker/index.ts +1 -0
- package/src/nile-date-time-picker/nile-date-time-picker.css.ts +54 -0
- package/src/nile-date-time-picker/nile-date-time-picker.test.ts +7886 -0
- package/src/nile-date-time-picker/nile-date-time-picker.ts +1153 -0
- package/vscode-html-custom-data.json +161 -6
|
@@ -1,5 +1,78 @@
|
|
|
1
1
|
import { expect, fixture, html } from '@open-wc/testing';
|
|
2
2
|
import './nile-calendar';
|
|
3
|
+
import '../nile-input';
|
|
4
|
+
import { formatYear4, localDateFromFields, isClockTime } from './timezones';
|
|
5
|
+
import { wallClockFromOffsetLess, instantFromBracketedValue } from './nile-calendar';
|
|
6
|
+
/**
|
|
7
|
+
* The PUBLISHED default-calendar algorithms (@aquera/nile-elements 2.0.16), kept as oracles. The default calendar
|
|
8
|
+
* (`<nile-calendar>` and `variant="default"`) must keep behaving exactly like this on every host timezone; only
|
|
9
|
+
* `variant="date-time-picker"` may differ. Tests that need "what the published calendar does" compare against these,
|
|
10
|
+
* so no expected value has to be hard-coded against a host zone.
|
|
11
|
+
*/
|
|
12
|
+
const published = {
|
|
13
|
+
/** `value` seeding: `new Date(raw)` shifted by the host offset of that instant; null when it is not a date */
|
|
14
|
+
seed(raw) {
|
|
15
|
+
const d = new Date(raw);
|
|
16
|
+
const shifted = new Date(d.getTime() - d.getTimezoneOffset() * 60000);
|
|
17
|
+
return isNaN(shifted.getTime()) ? null : shifted;
|
|
18
|
+
},
|
|
19
|
+
/** one bound of a `range` value */
|
|
20
|
+
bound(raw) { return new Date(raw); },
|
|
21
|
+
/** manual From/To input text */
|
|
22
|
+
format(date, dateFormat) {
|
|
23
|
+
const yyyy = date.getFullYear();
|
|
24
|
+
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
|
25
|
+
const dd = String(date.getDate()).padStart(2, '0');
|
|
26
|
+
let formatted = dateFormat;
|
|
27
|
+
formatted = formatted.replace(/YYYY/g, String(yyyy));
|
|
28
|
+
formatted = formatted.replace(/MM/g, mm);
|
|
29
|
+
formatted = formatted.replace(/DD/g, dd);
|
|
30
|
+
formatted = formatted.replace(/YY/g, String(yyyy).slice(-2));
|
|
31
|
+
return formatted;
|
|
32
|
+
},
|
|
33
|
+
/** manual From/To input parsing */
|
|
34
|
+
parse(input, dateFormat, existingDate) {
|
|
35
|
+
if (!input || !dateFormat)
|
|
36
|
+
return null;
|
|
37
|
+
const formatPattern = dateFormat
|
|
38
|
+
.replace(/YYYY/, '(?<year>\\d{4})')
|
|
39
|
+
.replace(/YY/, '(?<year>\\d{2})')
|
|
40
|
+
.replace(/MM/, '(?<month>\\d{1,2})')
|
|
41
|
+
.replace(/DD/, '(?<day>\\d{1,2})');
|
|
42
|
+
const match = input.match(new RegExp(`^${formatPattern}$`));
|
|
43
|
+
if (!match || !match.groups)
|
|
44
|
+
return null;
|
|
45
|
+
const year = match.groups.year.length === 2 ? Number(`20${match.groups.year}`) : Number(match.groups.year);
|
|
46
|
+
const month = Number(match.groups.month) - 1;
|
|
47
|
+
const day = Number(match.groups.day);
|
|
48
|
+
const date = new Date(year, month, day);
|
|
49
|
+
if (existingDate)
|
|
50
|
+
date.setHours(existingDate.getHours(), existingDate.getMinutes(), existingDate.getSeconds());
|
|
51
|
+
else
|
|
52
|
+
date.setHours(0, 0, 0);
|
|
53
|
+
return date;
|
|
54
|
+
},
|
|
55
|
+
/** allowedDates membership (Date.UTC remaps years 0-99 to 1900-1999) */
|
|
56
|
+
allowed(startDate, endDate, day, month, year) {
|
|
57
|
+
const utc = (str) => new Date(Date.UTC(str.slice(0, 4), str.slice(5, 7) - 1, str.slice(8, 10)));
|
|
58
|
+
// published: a range whose start reads later than its end is rejected, leaving every day allowed
|
|
59
|
+
if (utc(startDate) > utc(endDate))
|
|
60
|
+
return true;
|
|
61
|
+
const end = utc(endDate);
|
|
62
|
+
end.setUTCHours(23, 59, 59, 999);
|
|
63
|
+
const d = new Date(Date.UTC(year, month, day));
|
|
64
|
+
return d >= utc(startDate) && d <= end;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
/** the default calendar seeded `raw` exactly as published: the shifted Date, or no selection when `raw` is not a date */
|
|
68
|
+
const expectPublishedSeed = (el, raw, label) => {
|
|
69
|
+
const exp = published.seed(raw);
|
|
70
|
+
if (!exp) {
|
|
71
|
+
expect(el.value ?? null, label).to.equal(null);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
expect(el.value.getTime(), label).to.equal(exp.getTime());
|
|
75
|
+
};
|
|
3
76
|
describe('NileCalendar', () => {
|
|
4
77
|
it('1. renders', async () => { const el = await fixture(html `<nile-calendar></nile-calendar>`); expect(el).to.exist; });
|
|
5
78
|
it('2. shadow root', async () => { const el = await fixture(html `<nile-calendar></nile-calendar>`); expect(el.shadowRoot).to.not.be.null; });
|
|
@@ -105,4 +178,3511 @@ describe('NileCalendar', () => {
|
|
|
105
178
|
it('99. no nile-loader', async () => { const el = await fixture(html `<nile-calendar></nile-calendar>`); expect(el.shadowRoot.querySelector('nile-loader')).to.be.null; });
|
|
106
179
|
it('100. full integration', async () => { const el = await fixture(html `<nile-calendar class="cl" id="cl1" role="grid"></nile-calendar>`); expect(el.id).to.equal('cl1'); expect(el.shadowRoot).to.not.be.null; expect(el.shadowRoot.querySelector('.calendar')).to.exist; expect(el.shadowRoot.querySelectorAll('.day_name').length).to.be.greaterThan(0); });
|
|
107
180
|
});
|
|
181
|
+
describe('NileCalendar date-time-picker variant', () => {
|
|
182
|
+
const dtpFixture = () => fixture(html `<nile-calendar variant="date-time-picker"></nile-calendar>`);
|
|
183
|
+
const clickDay = (el, index = 0) => {
|
|
184
|
+
const days = el.shadowRoot.querySelectorAll('.day_date:not(.filler)');
|
|
185
|
+
days[index].click();
|
|
186
|
+
};
|
|
187
|
+
/** Day cells are focusable <div>s; without a role they are announced as bare day numbers, hiding
|
|
188
|
+
* month, year and selected state. Covers both the default grid and the date-time-picker variant. */
|
|
189
|
+
describe('V0. day cell accessibility', () => {
|
|
190
|
+
const firstReal = (el) => el.shadowRoot.querySelector('.day_date:not(.filler)');
|
|
191
|
+
for (const [name, make] of [
|
|
192
|
+
['default', () => fixture(html `<nile-calendar></nile-calendar>`)],
|
|
193
|
+
['variant', () => fixture(html `<nile-calendar variant="date-time-picker"></nile-calendar>`)],
|
|
194
|
+
]) {
|
|
195
|
+
it(`V0-${name}. a focusable day exposes a role and a full-date accessible name`, async () => {
|
|
196
|
+
const el = await make();
|
|
197
|
+
await el.updateComplete;
|
|
198
|
+
const cell = firstReal(el);
|
|
199
|
+
expect(cell, 'a real day cell exists').to.exist;
|
|
200
|
+
if (name === 'default') {
|
|
201
|
+
// published default markup: a day cell carries no role, name or state attributes
|
|
202
|
+
for (const attr of ['role', 'aria-label', 'aria-pressed', 'aria-current', 'aria-disabled', 'aria-hidden']) {
|
|
203
|
+
expect(cell.hasAttribute(attr), `default day cell has no ${attr}`).to.be.false;
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
expect(cell.getAttribute('role'), 'interactive role').to.equal('button');
|
|
208
|
+
const label = cell.getAttribute('aria-label') ?? '';
|
|
209
|
+
expect(label, 'names more than the bare day number').to.not.equal(cell.textContent.trim());
|
|
210
|
+
// must carry a year, so the name is unambiguous across months
|
|
211
|
+
expect(label, `aria-label "${label}" should include a year`).to.match(/\d{4}/);
|
|
212
|
+
expect(cell.getAttribute('aria-pressed'), 'selected state is exposed').to.be.oneOf(['true', 'false']);
|
|
213
|
+
});
|
|
214
|
+
it(`V0-${name}. filler days from adjacent months are hidden, not announced`, async () => {
|
|
215
|
+
const el = await make();
|
|
216
|
+
await el.updateComplete;
|
|
217
|
+
const filler = el.shadowRoot.querySelector('.day_date.filler');
|
|
218
|
+
if (!filler)
|
|
219
|
+
return; // a month can start exactly on the first grid column
|
|
220
|
+
if (name === 'default') {
|
|
221
|
+
expect(filler.hasAttribute('aria-hidden'), 'published default filler: not aria-hidden').to.be.false;
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
expect(filler.getAttribute('aria-hidden')).to.equal('true');
|
|
225
|
+
expect(filler.hasAttribute('role'), 'not a button — it does nothing').to.be.false;
|
|
226
|
+
expect(filler.getAttribute('tabindex'), 'not tabbable').to.equal('-1');
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
// ---- variant gate ----
|
|
231
|
+
it('V1. default variant renders no picker panel', async () => {
|
|
232
|
+
const el = await fixture(html `<nile-calendar></nile-calendar>`);
|
|
233
|
+
expect(el.shadowRoot.querySelector('.base--date-time-picker')).to.be.null;
|
|
234
|
+
expect(el.shadowRoot.querySelector('.tp-footer')).to.be.null;
|
|
235
|
+
expect(el.shadowRoot.querySelector('.tp-time-fields')).to.be.null;
|
|
236
|
+
});
|
|
237
|
+
// ---- structure ----
|
|
238
|
+
it('V2. renders footer, chevron, time fields and timezone', async () => {
|
|
239
|
+
const el = await dtpFixture();
|
|
240
|
+
expect(el.shadowRoot.querySelector('.base--date-time-picker')).to.exist;
|
|
241
|
+
expect(el.shadowRoot.querySelector('.tp-footer')).to.exist;
|
|
242
|
+
expect(el.shadowRoot.querySelector('.tp-chevron')).to.exist;
|
|
243
|
+
expect(el.shadowRoot.querySelector('.tp-time-fields')).to.exist;
|
|
244
|
+
expect(el.shadowRoot.querySelector('.tp-timezone')).to.exist;
|
|
245
|
+
expect(el.shadowRoot.querySelector('.tp-set-to-now-button')).to.exist;
|
|
246
|
+
});
|
|
247
|
+
it('V3. renders four time fields', async () => {
|
|
248
|
+
const el = await dtpFixture();
|
|
249
|
+
expect(el.shadowRoot.querySelectorAll('.tp-time-field').length).to.equal(4);
|
|
250
|
+
});
|
|
251
|
+
it('V4. no manual From/To date inputs', async () => {
|
|
252
|
+
const el = await dtpFixture();
|
|
253
|
+
expect(el.shadowRoot.querySelector('.calender-input')).to.be.null;
|
|
254
|
+
expect(el.shadowRoot.querySelector('.date-input')).to.be.null;
|
|
255
|
+
});
|
|
256
|
+
// ---- expand / collapse ----
|
|
257
|
+
it('V5. collapsed by default with 3 week rows (21 day cells)', async () => {
|
|
258
|
+
const el = await dtpFixture();
|
|
259
|
+
expect(el.shadowRoot.querySelector('.base--tp-expanded')).to.be.null;
|
|
260
|
+
expect(el.shadowRoot.querySelectorAll('.day_date').length).to.equal(21);
|
|
261
|
+
});
|
|
262
|
+
it('V6. chevron expands to the full month in place and collapses back', async () => {
|
|
263
|
+
const el = await dtpFixture();
|
|
264
|
+
const chevron = el.shadowRoot.querySelector('.tp-chevron');
|
|
265
|
+
chevron.click();
|
|
266
|
+
await el.updateComplete;
|
|
267
|
+
expect(el.shadowRoot.querySelector('.base--tp-expanded')).to.exist;
|
|
268
|
+
// the month is rendered by this element itself -- no nested calendar is composed
|
|
269
|
+
expect(el.shadowRoot.querySelector('nile-calendar')).to.be.null;
|
|
270
|
+
const expandedCells = el.shadowRoot.querySelectorAll('.day_date').length;
|
|
271
|
+
expect(expandedCells).to.be.greaterThan(27);
|
|
272
|
+
expect(expandedCells % 7).to.equal(0);
|
|
273
|
+
// every day of the displayed month is present
|
|
274
|
+
const daysInMonth = new Date(el.currentYear, el.currentMonth + 1, 0).getDate();
|
|
275
|
+
expect(el.shadowRoot.querySelectorAll('.day_date:not(.filler)').length).to.equal(daysInMonth);
|
|
276
|
+
chevron.click();
|
|
277
|
+
await el.updateComplete;
|
|
278
|
+
expect(el.shadowRoot.querySelector('.base--tp-expanded')).to.be.null;
|
|
279
|
+
expect(el.shadowRoot.querySelectorAll('.day_date').length).to.equal(21);
|
|
280
|
+
});
|
|
281
|
+
it('V7. expanded: day clicks select silently, no nile-change leaks before Apply', async () => {
|
|
282
|
+
const el = await dtpFixture();
|
|
283
|
+
let leaked = false;
|
|
284
|
+
el.addEventListener('nile-change', () => { leaked = true; });
|
|
285
|
+
el.shadowRoot.querySelector('.tp-chevron').click();
|
|
286
|
+
await el.updateComplete;
|
|
287
|
+
el.shadowRoot.querySelectorAll('.day_date:not(.filler)')[5].click();
|
|
288
|
+
await el.updateComplete;
|
|
289
|
+
expect(leaked).to.be.false;
|
|
290
|
+
// ...but the selection was captured: apply is enabled
|
|
291
|
+
const apply = el.shadowRoot.querySelector('.apply-button');
|
|
292
|
+
expect(apply.hasAttribute('disabled')).to.be.false;
|
|
293
|
+
// and Apply is what actually emits
|
|
294
|
+
apply.click();
|
|
295
|
+
await el.updateComplete;
|
|
296
|
+
expect(leaked).to.be.true;
|
|
297
|
+
});
|
|
298
|
+
it('V8. collapsed view contains today', async () => {
|
|
299
|
+
const el = await dtpFixture();
|
|
300
|
+
expect(el.shadowRoot.querySelector('.current__date__dot')).to.exist;
|
|
301
|
+
});
|
|
302
|
+
it('V9. collapsed window centers today: previous week on top, next week below', async () => {
|
|
303
|
+
const el = await dtpFixture();
|
|
304
|
+
const cells = el.shadowRoot.querySelectorAll('.day_date');
|
|
305
|
+
expect(cells.length).to.equal(21);
|
|
306
|
+
// first visible cell is the Sunday of the week BEFORE today's week
|
|
307
|
+
const today = new Date();
|
|
308
|
+
const windowStart = new Date(today);
|
|
309
|
+
windowStart.setDate(today.getDate() - today.getDay() - 7);
|
|
310
|
+
expect(cells[0].textContent.trim()).to.contain(String(windowStart.getDate()));
|
|
311
|
+
// today sits in the middle row (cells 7..13)
|
|
312
|
+
const dotCell = el.shadowRoot.querySelector('.current__date__dot').closest('.day_date');
|
|
313
|
+
const idx = Array.prototype.indexOf.call(cells, dotCell);
|
|
314
|
+
expect(idx).to.be.greaterThan(6);
|
|
315
|
+
expect(idx).to.be.lessThan(14);
|
|
316
|
+
});
|
|
317
|
+
// ---- selection / apply ----
|
|
318
|
+
it('V10. apply is disabled until a date is selected', async () => {
|
|
319
|
+
const el = await dtpFixture();
|
|
320
|
+
const apply = el.shadowRoot.querySelector('.apply-button');
|
|
321
|
+
expect(apply.hasAttribute('disabled')).to.be.true;
|
|
322
|
+
clickDay(el, 5);
|
|
323
|
+
await el.updateComplete;
|
|
324
|
+
expect(apply.hasAttribute('disabled')).to.be.false;
|
|
325
|
+
});
|
|
326
|
+
it('V11. day click does NOT emit nile-change (batched until apply)', async () => {
|
|
327
|
+
const el = await dtpFixture();
|
|
328
|
+
let emitted = false;
|
|
329
|
+
el.addEventListener('nile-change', () => { emitted = true; });
|
|
330
|
+
clickDay(el, 5);
|
|
331
|
+
await el.updateComplete;
|
|
332
|
+
expect(emitted).to.be.false;
|
|
333
|
+
});
|
|
334
|
+
it('V12. apply emits nile-change with value and timezone', async () => {
|
|
335
|
+
const el = await dtpFixture();
|
|
336
|
+
clickDay(el, 5);
|
|
337
|
+
await el.updateComplete;
|
|
338
|
+
let detail = null;
|
|
339
|
+
el.addEventListener('nile-change', ((e) => { detail = e.detail; }));
|
|
340
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
341
|
+
expect(detail).to.not.be.null;
|
|
342
|
+
// value is now a zone-encoded string: "YYYY-MM-DDTHH:mm:ss.SSS[GMT±HH:MM Zone]"
|
|
343
|
+
expect(detail.value).to.be.a('string');
|
|
344
|
+
expect(detail.value).to.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}\[GMT[+-]\d{2}:\d{2} .+\]$/);
|
|
345
|
+
expect(detail.value).to.include('[GMT+00:00 UTC]');
|
|
346
|
+
expect(detail.timezone).to.equal('UTC');
|
|
347
|
+
});
|
|
348
|
+
it('V13. time fields are merged into the applied value (selected-zone wall-clock)', async () => {
|
|
349
|
+
const el = await dtpFixture();
|
|
350
|
+
clickDay(el, 5);
|
|
351
|
+
el.tpHours = 14;
|
|
352
|
+
el.tpMinutes = 30;
|
|
353
|
+
el.tpSeconds = 5;
|
|
354
|
+
el.tpMilliseconds = 250;
|
|
355
|
+
await el.updateComplete;
|
|
356
|
+
let detail = null;
|
|
357
|
+
el.addEventListener('nile-change', ((e) => { detail = e.detail; }));
|
|
358
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
359
|
+
// value shows the picked wall-clock as-entered (the selected-zone time)
|
|
360
|
+
expect(detail.value).to.include('T14:30:05.250');
|
|
361
|
+
expect(detail.value).to.include('[GMT+00:00 UTC]');
|
|
362
|
+
});
|
|
363
|
+
it('V14. unset time fields apply as 00:00:00.000', async () => {
|
|
364
|
+
const el = await dtpFixture();
|
|
365
|
+
clickDay(el, 5);
|
|
366
|
+
await el.updateComplete;
|
|
367
|
+
let detail = null;
|
|
368
|
+
el.addEventListener('nile-change', ((e) => { detail = e.detail; }));
|
|
369
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
370
|
+
expect(detail.value).to.include('T00:00:00.000');
|
|
371
|
+
});
|
|
372
|
+
it('V14a. variant absolute payload includes localTimezoneValue (the browser-zone instant)', async () => {
|
|
373
|
+
const el = await dtpFixture();
|
|
374
|
+
clickDay(el, 5);
|
|
375
|
+
el.tpHours = 9;
|
|
376
|
+
el.tpMinutes = 0;
|
|
377
|
+
await el.updateComplete;
|
|
378
|
+
let detail = null;
|
|
379
|
+
el.addEventListener('nile-change', ((e) => { detail = e.detail; }));
|
|
380
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
381
|
+
expect(detail.localTimezoneValue).to.be.instanceOf(Date);
|
|
382
|
+
// `value` is the entered wall clock (09:00); `localTimezoneValue` is that same moment through the
|
|
383
|
+
// selected zone, so the two are equal only when the selected zone matches the browser zone.
|
|
384
|
+
expect(detail.value).to.include('T09:00:00');
|
|
385
|
+
});
|
|
386
|
+
// ---- relative tab ----
|
|
387
|
+
it('V15. relative selection applies startDate/endDate with timezone', async () => {
|
|
388
|
+
const el = await dtpFixture();
|
|
389
|
+
el.type = 'relative';
|
|
390
|
+
await el.updateComplete;
|
|
391
|
+
el.shadowRoot.querySelector('.duration__value').click();
|
|
392
|
+
await el.updateComplete;
|
|
393
|
+
let detail = null;
|
|
394
|
+
el.addEventListener('nile-change', ((e) => { detail = e.detail; }));
|
|
395
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
396
|
+
expect(detail).to.not.be.null;
|
|
397
|
+
expect(detail.startDate).to.be.instanceOf(Date);
|
|
398
|
+
expect(detail.endDate).to.be.instanceOf(Date);
|
|
399
|
+
expect(detail.timezone).to.equal('UTC');
|
|
400
|
+
});
|
|
401
|
+
// ---- timezone ----
|
|
402
|
+
it('V16. timezone defaults to UTC', async () => {
|
|
403
|
+
const el = await dtpFixture();
|
|
404
|
+
expect(el.timezone).to.equal('UTC');
|
|
405
|
+
});
|
|
406
|
+
it('V17. timezone property can be preset', async () => {
|
|
407
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" timezone="Asia/Kolkata"></nile-calendar>`);
|
|
408
|
+
expect(el.timezone).to.equal('Asia/Kolkata');
|
|
409
|
+
});
|
|
410
|
+
it('V17a. restores wall-clock and zone from an emitted (zone-encoded) value', async () => {
|
|
411
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" value="2026-07-15T14:30:05.250[GMT+05:30 Asia/Kolkata]"></nile-calendar>`);
|
|
412
|
+
await el.updateComplete;
|
|
413
|
+
expect(el.timezone).to.equal('Asia/Kolkata');
|
|
414
|
+
expect(el.value).to.be.instanceOf(Date);
|
|
415
|
+
expect(el.value.getFullYear()).to.equal(2026);
|
|
416
|
+
expect(el.value.getMonth()).to.equal(6); // July (0-indexed)
|
|
417
|
+
expect(el.value.getDate()).to.equal(15);
|
|
418
|
+
expect(el.tpHours).to.equal(14);
|
|
419
|
+
expect(el.tpMinutes).to.equal(30);
|
|
420
|
+
expect(el.tpSeconds).to.equal(5);
|
|
421
|
+
expect(el.tpMilliseconds).to.equal(250);
|
|
422
|
+
});
|
|
423
|
+
it('V17b. an emitted value fed back in re-emits identically (round-trip)', async () => {
|
|
424
|
+
const original = '2026-07-15T14:30:05.250[GMT+05:30 Asia/Kolkata]';
|
|
425
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" value="${original}"></nile-calendar>`);
|
|
426
|
+
await el.updateComplete;
|
|
427
|
+
let detail = null;
|
|
428
|
+
el.addEventListener('nile-change', ((e) => { detail = e.detail; }));
|
|
429
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
430
|
+
expect(detail).to.not.be.null;
|
|
431
|
+
expect(detail.value).to.equal(original);
|
|
432
|
+
expect(detail.timezone).to.equal('Asia/Kolkata');
|
|
433
|
+
});
|
|
434
|
+
it('V17c. a non-zoned value still parses and leaves timezone at default', async () => {
|
|
435
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" value="2026-07-15"></nile-calendar>`);
|
|
436
|
+
await el.updateComplete;
|
|
437
|
+
expect(el.value).to.be.instanceOf(Date);
|
|
438
|
+
expect(el.value.getDate()).to.equal(15);
|
|
439
|
+
expect(el.timezone).to.equal('UTC');
|
|
440
|
+
});
|
|
441
|
+
it('V17d. an unrecognized zone in the value falls back to UTC (wall-clock kept)', async () => {
|
|
442
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" value="2026-07-15T14:30:05.250[GMT+05:30 Mars/Phobos]"></nile-calendar>`);
|
|
443
|
+
await el.updateComplete;
|
|
444
|
+
expect(el.timezone).to.equal('UTC');
|
|
445
|
+
expect(el.value).to.be.instanceOf(Date);
|
|
446
|
+
expect(el.value.getDate()).to.equal(15);
|
|
447
|
+
expect(el.tpHours).to.equal(14);
|
|
448
|
+
expect(el.tpMinutes).to.equal(30);
|
|
449
|
+
});
|
|
450
|
+
it('V17e. an unrecognized-zone fallback keeps the month (March stays March)', async () => {
|
|
451
|
+
// Mars/Phobos fails instantFromBracketedValue, so this seeds through the parseZonedValue fallback.
|
|
452
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" value="2026-03-15T14:30:05.250[GMT+05:30 Mars/Phobos]"></nile-calendar>`);
|
|
453
|
+
await el.updateComplete;
|
|
454
|
+
expect(el.timezone).to.equal('UTC');
|
|
455
|
+
expect(el.value.getFullYear()).to.equal(2026);
|
|
456
|
+
expect(el.value.getMonth()).to.equal(2); // March (0-indexed)
|
|
457
|
+
expect(el.value.getDate()).to.equal(15);
|
|
458
|
+
expect(el.currentMonth).to.equal(2);
|
|
459
|
+
const march = new Date(2026, 2).toLocaleString('default', { month: 'long' });
|
|
460
|
+
expect(el.shadowRoot.querySelector('.calendar-header__center').textContent).to.contain(march);
|
|
461
|
+
});
|
|
462
|
+
it('V17f. externally clearing the value attribute also clears the time spinners', async () => {
|
|
463
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" value="2026-07-15T14:30:05.250[GMT+05:30 Asia/Kolkata]"></nile-calendar>`);
|
|
464
|
+
await el.updateComplete;
|
|
465
|
+
expect(el.tpHours).to.equal(14);
|
|
466
|
+
el.setAttribute('value', '');
|
|
467
|
+
await el.updateComplete;
|
|
468
|
+
expect(el.value).to.be.null;
|
|
469
|
+
expect(el.tpHours).to.be.null;
|
|
470
|
+
expect(el.tpMinutes).to.be.null;
|
|
471
|
+
expect(el.tpSeconds).to.be.null;
|
|
472
|
+
expect(el.tpMilliseconds).to.be.null;
|
|
473
|
+
expect(el.timezone, 'clearing the value must not reset the zone').to.equal('Asia/Kolkata');
|
|
474
|
+
});
|
|
475
|
+
it('V17g. clicking the already-selected seeded day keeps the seeded time', async () => {
|
|
476
|
+
// seeding stores the day at noon; a click builds midnight — same day, so the time must survive
|
|
477
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" value="2026-07-15T14:30:05.250[GMT+05:30 Asia/Kolkata]"></nile-calendar>`);
|
|
478
|
+
await el.updateComplete;
|
|
479
|
+
el.shadowRoot.querySelector('.day_date.selected-date:not(.filler)').click();
|
|
480
|
+
await el.updateComplete;
|
|
481
|
+
expect(el.value.getDate()).to.equal(15);
|
|
482
|
+
expect(el.tpHours).to.equal(14);
|
|
483
|
+
expect(el.tpMinutes).to.equal(30);
|
|
484
|
+
expect(el.tpSeconds).to.equal(5);
|
|
485
|
+
expect(el.tpMilliseconds).to.equal(250);
|
|
486
|
+
});
|
|
487
|
+
it('V17h. doubleClickUnselect: one click on the seeded selected day unselects it', async () => {
|
|
488
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" .doubleClickUnselect=${true} value="2026-07-15T14:30:05.250[GMT+05:30 Asia/Kolkata]"></nile-calendar>`);
|
|
489
|
+
await el.updateComplete;
|
|
490
|
+
expect(el.doubleClickUnselect).to.be.true;
|
|
491
|
+
let cleared = false;
|
|
492
|
+
el.addEventListener('nile-clear', () => { cleared = true; });
|
|
493
|
+
el.shadowRoot.querySelector('.day_date.selected-date:not(.filler)').click();
|
|
494
|
+
await el.updateComplete;
|
|
495
|
+
expect(el.value).to.be.null;
|
|
496
|
+
expect(el.tpHours).to.be.null;
|
|
497
|
+
expect(cleared).to.be.true;
|
|
498
|
+
});
|
|
499
|
+
it('V18. use local timezone link sets the browser timezone', async () => {
|
|
500
|
+
const el = await dtpFixture();
|
|
501
|
+
el.shadowRoot.querySelector('.tp-timezone-local-link').click();
|
|
502
|
+
await el.updateComplete;
|
|
503
|
+
expect(el.timezone).to.equal(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
|
504
|
+
});
|
|
505
|
+
// The dropdown is built from Intl.supportedValuesOf while the parser validated against a static list;
|
|
506
|
+
// they disagree on ~70 zones, so a picked zone was silently downgraded to UTC. These cover that seam.
|
|
507
|
+
describe('timezone alias round-trip (validated via Intl, not the static list)', () => {
|
|
508
|
+
// `Asia/Calcutta` is what Chrome's Intl reports for India and is deliberately absent from the static
|
|
509
|
+
// TIMEZONES list, so a membership validator drops +05:30. `isValidTimezone` asks Intl and echoes it back.
|
|
510
|
+
const ALIAS = 'Asia/Calcutta';
|
|
511
|
+
const ALIAS_VALUE = `2026-07-15T14:30:05.250[GMT+05:30 ${ALIAS}]`;
|
|
512
|
+
// 14:30:05.250 at +05:30 → 09:00:05.250Z. Derived from the ZONE, so this number is
|
|
513
|
+
// correct on every host.
|
|
514
|
+
const ALIAS_EPOCH = Date.UTC(2026, 6, 15, 9, 0, 5, 250);
|
|
515
|
+
const applyAndCapture = async (el) => {
|
|
516
|
+
let detail = null;
|
|
517
|
+
el.addEventListener('nile-change', ((e) => { detail = e.detail; }));
|
|
518
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
519
|
+
await el.updateComplete;
|
|
520
|
+
return detail;
|
|
521
|
+
};
|
|
522
|
+
it('TZ1. an aliased zone in `value` survives Apply → reload → Apply', async () => {
|
|
523
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" value="${ALIAS_VALUE}"></nile-calendar>`);
|
|
524
|
+
await el.updateComplete;
|
|
525
|
+
expect(el.timezone, 'a VALID alias must not be downgraded to UTC').to.equal(ALIAS);
|
|
526
|
+
const first = await applyAndCapture(el);
|
|
527
|
+
expect(first.timezone).to.equal(ALIAS);
|
|
528
|
+
expect(first.unixEpoch, 'resolved through the alias at +05:30').to.equal(ALIAS_EPOCH);
|
|
529
|
+
expect(first.localTimezoneValue.getTime(), 'localTimezoneValue === unixEpoch')
|
|
530
|
+
.to.equal(first.unixEpoch);
|
|
531
|
+
expect(first.value, 'the supplied spelling is echoed back, not canonicalized')
|
|
532
|
+
.to.contain(`[GMT+05:30 ${ALIAS}]`);
|
|
533
|
+
expect(first.value, 'and the zone reading of the instant').to.contain('2026-07-15T14:30:05.250');
|
|
534
|
+
// emit → store → reload: feed the emitted value into a FRESH component
|
|
535
|
+
const el2 = await fixture(html `<nile-calendar variant="date-time-picker" value="${first.value}"></nile-calendar>`);
|
|
536
|
+
await el2.updateComplete;
|
|
537
|
+
expect(el2.timezone, 'still not downgraded on the second pass').to.equal(ALIAS);
|
|
538
|
+
const second = await applyAndCapture(el2);
|
|
539
|
+
expect(second.unixEpoch, 'the instant survives emit → store → reload → Apply')
|
|
540
|
+
.to.equal(first.unixEpoch);
|
|
541
|
+
expect(second.value, 'and the serialization is stable').to.equal(first.value);
|
|
542
|
+
expect(second.timezone).to.equal(ALIAS);
|
|
543
|
+
});
|
|
544
|
+
// NOTE: TZ2 does NOT exercise `isValidTimezone` — assigning `timezone` directly bypasses validation.
|
|
545
|
+
// Kept as a contract test for the SELECT path; TZ1 and TZ3 are the ones with teeth against a regression.
|
|
546
|
+
it('TZ2. an aliased zone SELECTED on the panel resolves through the alias', async () => {
|
|
547
|
+
const el = await dtpFixture();
|
|
548
|
+
el.timezone = ALIAS;
|
|
549
|
+
el.currentYear = 2026;
|
|
550
|
+
el.currentMonth = 6; // July, 0-indexed
|
|
551
|
+
await el.updateComplete;
|
|
552
|
+
clickDay(el, 14); // the 15th non-filler cell → 2026-07-15
|
|
553
|
+
el.tpHours = 14;
|
|
554
|
+
el.tpMinutes = 30;
|
|
555
|
+
el.tpSeconds = 5;
|
|
556
|
+
el.tpMilliseconds = 250;
|
|
557
|
+
await el.updateComplete;
|
|
558
|
+
const detail = await applyAndCapture(el);
|
|
559
|
+
expect(detail.timezone, 'the selected spelling is kept').to.equal(ALIAS);
|
|
560
|
+
expect(detail.unixEpoch, 'the picked wall clock read in the ALIAS zone')
|
|
561
|
+
.to.equal(ALIAS_EPOCH);
|
|
562
|
+
expect(detail.value).to.contain(`[GMT+05:30 ${ALIAS}]`);
|
|
563
|
+
});
|
|
564
|
+
it('TZ3. the alias and its canonical spelling denote the SAME instant', async () => {
|
|
565
|
+
// Same digits, two valid spellings of one zone. A downgrade to UTC would move
|
|
566
|
+
// only the aliased one, so this pins the two together.
|
|
567
|
+
const seen = [];
|
|
568
|
+
for (const zone of [ALIAS, 'Asia/Kolkata']) {
|
|
569
|
+
const el = await fixture(html `<nile-calendar
|
|
570
|
+
variant="date-time-picker"
|
|
571
|
+
value="2026-07-15T14:30:05.250[GMT+05:30 ${zone}]"
|
|
572
|
+
></nile-calendar>`);
|
|
573
|
+
await el.updateComplete;
|
|
574
|
+
expect(el.timezone, `${zone}: spelling preserved`).to.equal(zone);
|
|
575
|
+
seen.push({ zone, epoch: (await applyAndCapture(el)).unixEpoch });
|
|
576
|
+
}
|
|
577
|
+
expect(seen[0].epoch, 'alias and canonical must resolve identically')
|
|
578
|
+
.to.equal(seen[1].epoch);
|
|
579
|
+
expect(seen[0].epoch).to.equal(ALIAS_EPOCH);
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
// allowedDates used to be cosmetic in this variant: a .not-allowed cell was still selectable. Now
|
|
583
|
+
// enforced in tpSelectDate; the DEFAULT calendar keeps its behaviour, as changing it would move nile-date-picker.
|
|
584
|
+
describe('allowedDates enforcement', () => {
|
|
585
|
+
const ALLOWED = JSON.stringify({ startDate: '2026-06-10', endDate: '2026-06-20' });
|
|
586
|
+
const june = async () => {
|
|
587
|
+
const el = await fixture(html `
|
|
588
|
+
<nile-calendar variant="date-time-picker" allowedDates=${ALLOWED}></nile-calendar>`);
|
|
589
|
+
el.currentYear = 2026;
|
|
590
|
+
el.currentMonth = 5;
|
|
591
|
+
el.expanded = true;
|
|
592
|
+
await el.updateComplete;
|
|
593
|
+
return el;
|
|
594
|
+
};
|
|
595
|
+
const blockedCell = (el) => el.shadowRoot.querySelector('.day_date.not-allowed:not(.filler)');
|
|
596
|
+
it('V21. a disallowed day cannot be selected by click', async () => {
|
|
597
|
+
const el = await june();
|
|
598
|
+
const cell = blockedCell(el);
|
|
599
|
+
expect(cell, 'fixture should render a blocked cell').to.exist;
|
|
600
|
+
expect(cell.getAttribute('aria-disabled')).to.equal('true');
|
|
601
|
+
cell.click();
|
|
602
|
+
await el.updateComplete;
|
|
603
|
+
expect(el.value, 'disallowed date must not be selected').to.be.null;
|
|
604
|
+
expect(el.shadowRoot.querySelector('.apply-button').hasAttribute('disabled'), 'Apply must stay disabled').to.be.true;
|
|
605
|
+
});
|
|
606
|
+
it('V22. a disallowed day cannot be selected by keyboard either', async () => {
|
|
607
|
+
const el = await june();
|
|
608
|
+
const cell = blockedCell(el);
|
|
609
|
+
for (const key of ['Enter', ' ']) {
|
|
610
|
+
cell.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
|
611
|
+
await el.updateComplete;
|
|
612
|
+
expect(el.value, `${key} must not select a disallowed date`).to.be.null;
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
it('V23. an allowed day is still selectable', async () => {
|
|
616
|
+
const el = await june();
|
|
617
|
+
const allowed = Array.from(el.shadowRoot.querySelectorAll('.day_date:not(.filler):not(.not-allowed)'));
|
|
618
|
+
expect(allowed.length, 'some days must remain selectable').to.be.greaterThan(0);
|
|
619
|
+
allowed[0].click();
|
|
620
|
+
await el.updateComplete;
|
|
621
|
+
expect(el.value).to.not.be.null;
|
|
622
|
+
expect(el.value.getMonth()).to.equal(5);
|
|
623
|
+
});
|
|
624
|
+
it('V24. an aria-hidden filler cell is not keyboard-selectable', async () => {
|
|
625
|
+
const el = await june();
|
|
626
|
+
const filler = el.shadowRoot.querySelector('.day_date.filler');
|
|
627
|
+
if (filler) {
|
|
628
|
+
filler.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
|
629
|
+
await el.updateComplete;
|
|
630
|
+
expect(el.value).to.be.null;
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
it('V25. the DEFAULT calendar keeps its existing (unguarded) behaviour', async () => {
|
|
634
|
+
const el = await fixture(html `
|
|
635
|
+
<nile-calendar allowedDates=${ALLOWED}></nile-calendar>`);
|
|
636
|
+
el.currentYear = 2026;
|
|
637
|
+
el.currentMonth = 5;
|
|
638
|
+
await el.updateComplete;
|
|
639
|
+
const cell = el.shadowRoot.querySelector('.day_date.not-allowed:not(.filler)');
|
|
640
|
+
expect(cell, 'default should still render blocked cells').to.exist;
|
|
641
|
+
cell.click();
|
|
642
|
+
await el.updateComplete;
|
|
643
|
+
// documents the deliberate scope of the fix: the variant only
|
|
644
|
+
expect(el.value, 'default calendar behaviour is intentionally unchanged').to.not.be.null;
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
it('V19a. every zone offered in the dropdown survives a value round-trip', async () => {
|
|
648
|
+
const el = await dtpFixture();
|
|
649
|
+
const select = el.shadowRoot.querySelector('.tp-timezone-select');
|
|
650
|
+
const offered = Array.from(select.querySelectorAll('nile-option'), (opt) => opt.getAttribute('value'));
|
|
651
|
+
expect(offered.length).to.be.greaterThan(300);
|
|
652
|
+
const downgraded = [];
|
|
653
|
+
for (const zone of offered) {
|
|
654
|
+
const probe = await fixture(html `<nile-calendar
|
|
655
|
+
variant="date-time-picker"
|
|
656
|
+
value="2026-06-15T14:30:05.250[GMT+05:30 ${zone}]"
|
|
657
|
+
></nile-calendar>`);
|
|
658
|
+
if (probe.timezone !== zone)
|
|
659
|
+
downgraded.push(`${zone} -> ${probe.timezone}`);
|
|
660
|
+
}
|
|
661
|
+
expect(downgraded, `zones lost on read-back: ${downgraded.slice(0, 5).join(', ')}`).to.deep.equal([]);
|
|
662
|
+
});
|
|
663
|
+
it('V19b. the host zone survives "use local timezone" -> Apply -> re-read', async () => {
|
|
664
|
+
const hostZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
665
|
+
const el = await dtpFixture();
|
|
666
|
+
clickDay(el, 5);
|
|
667
|
+
await el.updateComplete;
|
|
668
|
+
el.shadowRoot.querySelector('.tp-timezone-local-link').click();
|
|
669
|
+
await el.updateComplete;
|
|
670
|
+
expect(el.timezone).to.equal(hostZone);
|
|
671
|
+
let emitted = null;
|
|
672
|
+
el.addEventListener('nile-change', (e) => { emitted = e.detail; });
|
|
673
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
674
|
+
await el.updateComplete;
|
|
675
|
+
expect(emitted.timezone).to.equal(hostZone);
|
|
676
|
+
// feeding the emitted value back in must not shift the instant
|
|
677
|
+
const back = await fixture(html `<nile-calendar variant="date-time-picker" value="${emitted.value}"></nile-calendar>`);
|
|
678
|
+
expect(back.timezone, 'host zone must not be downgraded to UTC').to.equal(hostZone);
|
|
679
|
+
let reapplied = null;
|
|
680
|
+
back.addEventListener('nile-change', (e) => { reapplied = e.detail; });
|
|
681
|
+
back.shadowRoot.querySelector('.apply-button').click();
|
|
682
|
+
await back.updateComplete;
|
|
683
|
+
expect(reapplied.unixEpoch, 'instant must be stable across a round-trip').to.equal(emitted.unixEpoch);
|
|
684
|
+
});
|
|
685
|
+
it('V19c. an invalid zone in a value still falls back to UTC', async () => {
|
|
686
|
+
for (const bad of ['Foo/Bar', 'GMT+5', 'Not A Zone']) {
|
|
687
|
+
const el = await fixture(html `<nile-calendar
|
|
688
|
+
variant="date-time-picker"
|
|
689
|
+
value="2026-06-15T14:30:05.250[GMT+05:30 ${bad}]"
|
|
690
|
+
></nile-calendar>`);
|
|
691
|
+
expect(el.timezone, `${bad} should not be accepted`).to.equal('UTC');
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
it('V19. timezone options come from the browser and include UTC and the local zone', async () => {
|
|
695
|
+
const el = await dtpFixture();
|
|
696
|
+
const select = el.shadowRoot.querySelector('.tp-timezone-select');
|
|
697
|
+
const options = Array.from(select.querySelectorAll('nile-option'), (opt) => opt.getAttribute('value'));
|
|
698
|
+
expect(options).to.include('UTC');
|
|
699
|
+
// the local zone must always be selectable — the reason for using Intl
|
|
700
|
+
expect(options).to.include(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
|
701
|
+
expect(options.length).to.be.greaterThan(300);
|
|
702
|
+
});
|
|
703
|
+
// ---- set to now ----
|
|
704
|
+
it('V20. set to now fills time fields and selects today when nothing selected', async () => {
|
|
705
|
+
const el = await dtpFixture();
|
|
706
|
+
el.shadowRoot.querySelector('.tp-set-to-now-button').click();
|
|
707
|
+
await el.updateComplete;
|
|
708
|
+
expect(el.tpHours).to.be.a('number');
|
|
709
|
+
expect(el.tpMinutes).to.be.a('number');
|
|
710
|
+
expect(el.tpSeconds).to.be.a('number');
|
|
711
|
+
expect(el.tpMilliseconds).to.be.a('number');
|
|
712
|
+
expect(el.value).to.be.instanceOf(Date);
|
|
713
|
+
const today = new Date();
|
|
714
|
+
expect(el.value.getDate()).to.equal(today.getDate());
|
|
715
|
+
expect(el.value.getMonth()).to.equal(today.getMonth());
|
|
716
|
+
});
|
|
717
|
+
it('V21. set to now overrides an already-selected date with today and sets the local timezone', async () => {
|
|
718
|
+
const el = await dtpFixture();
|
|
719
|
+
clickDay(el, 2);
|
|
720
|
+
await el.updateComplete;
|
|
721
|
+
el.shadowRoot.querySelector('.tp-set-to-now-button').click();
|
|
722
|
+
await el.updateComplete;
|
|
723
|
+
const today = new Date();
|
|
724
|
+
expect(el.value.getDate()).to.equal(today.getDate());
|
|
725
|
+
expect(el.value.getMonth()).to.equal(today.getMonth());
|
|
726
|
+
expect(el.timezone).to.equal(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
|
727
|
+
});
|
|
728
|
+
it('V21b. disabled: Set to now ignores click and Enter/Space, and its glyph uses the disabled token', async () => {
|
|
729
|
+
const el = await dtpFixture();
|
|
730
|
+
el.disabled = true;
|
|
731
|
+
await el.updateComplete;
|
|
732
|
+
const btn = el.shadowRoot.querySelector('.tp-set-to-now-button');
|
|
733
|
+
btn.click();
|
|
734
|
+
for (const key of ['Enter', ' '])
|
|
735
|
+
btn.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, composed: true }));
|
|
736
|
+
await el.updateComplete;
|
|
737
|
+
expect(el.value, 'no date selected').to.be.null;
|
|
738
|
+
expect(el.tpHours, 'no time filled').to.be.null;
|
|
739
|
+
expect(btn.hasAttribute('disabled')).to.be.true;
|
|
740
|
+
expect(btn.querySelector('nile-glyph').getAttribute('color')).to.equal('var(--nile-colors-dark-500, var(--ng-colors-fg-disabled))');
|
|
741
|
+
el.disabled = false;
|
|
742
|
+
await el.updateComplete;
|
|
743
|
+
expect(btn.querySelector('nile-glyph').hasAttribute('color'), 'enabled keeps the default color').to.be.false;
|
|
744
|
+
});
|
|
745
|
+
// ---- reset ----
|
|
746
|
+
it('V22. reset clears date, time fields and restores the configured timezone (default UTC) and emits nile-clear', async () => {
|
|
747
|
+
const el = await dtpFixture();
|
|
748
|
+
clickDay(el, 5);
|
|
749
|
+
el.tpHours = 9;
|
|
750
|
+
el.handleTimezoneChange(new CustomEvent('nile-change', { detail: { value: 'Asia/Kolkata' } })); // picked in the dropdown, not configured
|
|
751
|
+
await el.updateComplete;
|
|
752
|
+
expect(el.timezone).to.equal('Asia/Kolkata');
|
|
753
|
+
let cleared = false;
|
|
754
|
+
el.addEventListener('nile-clear', () => { cleared = true; });
|
|
755
|
+
el.shadowRoot.querySelector('.clear-button').click();
|
|
756
|
+
await el.updateComplete;
|
|
757
|
+
expect(el.value).to.be.null;
|
|
758
|
+
expect(el.tpHours).to.be.null;
|
|
759
|
+
expect(el.timezone).to.equal('UTC');
|
|
760
|
+
expect(cleared).to.be.true;
|
|
761
|
+
});
|
|
762
|
+
it('V23. relative tab has no Set to now; absolute tab does', async () => {
|
|
763
|
+
const el = await dtpFixture();
|
|
764
|
+
expect(el.shadowRoot.querySelector('.tp-set-to-now-button')).to.exist;
|
|
765
|
+
el.type = 'relative';
|
|
766
|
+
await el.updateComplete;
|
|
767
|
+
expect(el.shadowRoot.querySelector('.tp-set-to-now-button')).to.be.null;
|
|
768
|
+
expect(el.shadowRoot.querySelector('.tp-footer--relative')).to.exist;
|
|
769
|
+
// reset/apply still present, like the default variant footer
|
|
770
|
+
expect(el.shadowRoot.querySelector('.clear-button')).to.exist;
|
|
771
|
+
expect(el.shadowRoot.querySelector('.apply-button')).to.exist;
|
|
772
|
+
});
|
|
773
|
+
// The date-time-picker grid is a 21-cell window around today that re-flows after a selection, so
|
|
774
|
+
// cell INDICES are not stable (and depend on today's date). Select by the date a click actually
|
|
775
|
+
// produces instead: try cells until `value` lands on a different calendar day. Uses only local
|
|
776
|
+
// y/m/d from `value`, so it is independent of the host timezone.
|
|
777
|
+
const dayKey = (d) => (d ? `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` : null);
|
|
778
|
+
const clickDifferentDay = async (el) => {
|
|
779
|
+
const before = dayKey(el.value);
|
|
780
|
+
const cells = Array.from(el.shadowRoot.querySelectorAll('.day_date:not(.filler)'));
|
|
781
|
+
for (const cell of cells) {
|
|
782
|
+
cell.click();
|
|
783
|
+
await el.updateComplete;
|
|
784
|
+
const now = dayKey(el.value);
|
|
785
|
+
if (now !== before)
|
|
786
|
+
return now;
|
|
787
|
+
}
|
|
788
|
+
throw new Error('no selectable day produced a different value');
|
|
789
|
+
};
|
|
790
|
+
const setTime = async (el) => {
|
|
791
|
+
el.tpHours = 9;
|
|
792
|
+
el.tpMinutes = 45;
|
|
793
|
+
el.tpSeconds = 30;
|
|
794
|
+
el.tpMilliseconds = 500;
|
|
795
|
+
await el.updateComplete;
|
|
796
|
+
};
|
|
797
|
+
it('V24. picking a different date resets the time fields', async () => {
|
|
798
|
+
const el = await dtpFixture();
|
|
799
|
+
const first = await clickDifferentDay(el);
|
|
800
|
+
await setTime(el);
|
|
801
|
+
const second = await clickDifferentDay(el);
|
|
802
|
+
expect(second, 'precondition: a genuinely different day was selected').to.not.equal(first);
|
|
803
|
+
expect(el.tpHours).to.be.null;
|
|
804
|
+
expect(el.tpMinutes).to.be.null;
|
|
805
|
+
expect(el.tpSeconds).to.be.null;
|
|
806
|
+
expect(el.tpMilliseconds).to.be.null;
|
|
807
|
+
});
|
|
808
|
+
it('V24b. selecting the SAME date again keeps the time fields', async () => {
|
|
809
|
+
const el = await dtpFixture();
|
|
810
|
+
const picked = await clickDifferentDay(el);
|
|
811
|
+
await setTime(el);
|
|
812
|
+
const selected = el.shadowRoot.querySelector('.day_date.selected-date');
|
|
813
|
+
expect(selected, 'precondition: the picked day is rendered as selected').to.exist;
|
|
814
|
+
selected.click();
|
|
815
|
+
await el.updateComplete;
|
|
816
|
+
expect(dayKey(el.value), 'still the same day').to.equal(picked);
|
|
817
|
+
expect(el.tpHours).to.equal(9);
|
|
818
|
+
expect(el.tpMinutes).to.equal(45);
|
|
819
|
+
expect(el.tpSeconds).to.equal(30);
|
|
820
|
+
expect(el.tpMilliseconds).to.equal(500);
|
|
821
|
+
});
|
|
822
|
+
it('V24c. a different date, then back to a new one, resets the time each time', async () => {
|
|
823
|
+
const el = await dtpFixture();
|
|
824
|
+
await clickDifferentDay(el);
|
|
825
|
+
await setTime(el);
|
|
826
|
+
await clickDifferentDay(el);
|
|
827
|
+
expect(el.tpHours).to.be.null;
|
|
828
|
+
await setTime(el);
|
|
829
|
+
await clickDifferentDay(el);
|
|
830
|
+
expect(el.tpHours).to.be.null;
|
|
831
|
+
expect(el.tpMilliseconds).to.be.null;
|
|
832
|
+
});
|
|
833
|
+
// ---- time-field auto-correction (over-max -> 0 + first digit, no error) ----
|
|
834
|
+
const typeTimeField = async (el, index, raw) => {
|
|
835
|
+
const field = el.shadowRoot.querySelectorAll('.tp-time-field')[index];
|
|
836
|
+
field.dispatchEvent(new CustomEvent('nile-focus', { detail: {} }));
|
|
837
|
+
field.value = raw;
|
|
838
|
+
field.dispatchEvent(new CustomEvent('nile-input', { detail: { value: raw } }));
|
|
839
|
+
await el.updateComplete;
|
|
840
|
+
return field;
|
|
841
|
+
};
|
|
842
|
+
it('V25. an over-max hour auto-corrects to 0 + first digit (67 -> 06)', async () => {
|
|
843
|
+
const el = await dtpFixture();
|
|
844
|
+
clickDay(el, 5);
|
|
845
|
+
await el.updateComplete;
|
|
846
|
+
const hours = await typeTimeField(el, 0, '67');
|
|
847
|
+
expect(el.tpHours).to.equal(6);
|
|
848
|
+
expect(hours.value).to.equal('06');
|
|
849
|
+
});
|
|
850
|
+
it('V26. 28 -> 02 keeps the first digit; a valid value passes through', async () => {
|
|
851
|
+
const el = await dtpFixture();
|
|
852
|
+
clickDay(el, 5);
|
|
853
|
+
await el.updateComplete;
|
|
854
|
+
await typeTimeField(el, 0, '28');
|
|
855
|
+
expect(el.tpHours).to.equal(2);
|
|
856
|
+
const hours = await typeTimeField(el, 0, '21');
|
|
857
|
+
expect(el.tpHours).to.equal(21);
|
|
858
|
+
expect(hours.value).to.equal('21');
|
|
859
|
+
});
|
|
860
|
+
it('V27. the same correction applies to minutes (67 -> 06)', async () => {
|
|
861
|
+
const el = await dtpFixture();
|
|
862
|
+
clickDay(el, 5);
|
|
863
|
+
await el.updateComplete;
|
|
864
|
+
const minutes = await typeTimeField(el, 1, '67');
|
|
865
|
+
expect(el.tpMinutes).to.equal(6);
|
|
866
|
+
expect(minutes.value).to.equal('06');
|
|
867
|
+
});
|
|
868
|
+
it('V28. no error state is set on the time fields', async () => {
|
|
869
|
+
const el = await dtpFixture();
|
|
870
|
+
clickDay(el, 5);
|
|
871
|
+
await el.updateComplete;
|
|
872
|
+
const hours = await typeTimeField(el, 0, '99');
|
|
873
|
+
expect(hours.error).to.not.be.ok; // never flagged red
|
|
874
|
+
});
|
|
875
|
+
it('V29. time fields strip non-digits (-, ., letters)', async () => {
|
|
876
|
+
const el = await dtpFixture();
|
|
877
|
+
clickDay(el, 5);
|
|
878
|
+
await el.updateComplete;
|
|
879
|
+
let hours = await typeTimeField(el, 0, '0-4');
|
|
880
|
+
expect(el.tpHours).to.equal(4);
|
|
881
|
+
expect(hours.value).to.equal('04');
|
|
882
|
+
hours = await typeTimeField(el, 0, '09.');
|
|
883
|
+
expect(el.tpHours).to.equal(9);
|
|
884
|
+
expect(hours.value).to.equal('09');
|
|
885
|
+
});
|
|
886
|
+
it('V30. odometer entry: digits shift in from the right over "00"', async () => {
|
|
887
|
+
const el = await dtpFixture();
|
|
888
|
+
clickDay(el, 5); // all time fields show 00
|
|
889
|
+
await el.updateComplete;
|
|
890
|
+
// simulate typing "1" then "4" into the pre-filled "00" hours field
|
|
891
|
+
let hours = await typeTimeField(el, 0, '001'); // 00 + 1
|
|
892
|
+
expect(hours.value).to.equal('01');
|
|
893
|
+
hours = await typeTimeField(el, 0, '0014'); // ...then 4
|
|
894
|
+
expect(hours.value).to.equal('14');
|
|
895
|
+
expect(el.tpHours).to.equal(14);
|
|
896
|
+
});
|
|
897
|
+
});
|
|
898
|
+
/** Default ↔ date-time-picker payload parity: the variant emits the SAME events carrying a SUPERSET of
|
|
899
|
+
* the default payload (plus its own `timezone`), and adding it changes nothing the default emits or renders. */
|
|
900
|
+
describe('NileCalendar default ↔ date-time-picker payload parity & zero-breakage', () => {
|
|
901
|
+
const defaultSingle = () => fixture(html `<nile-calendar></nile-calendar>`);
|
|
902
|
+
const defaultRange = () => fixture(html `<nile-calendar range></nile-calendar>`);
|
|
903
|
+
const variant = () => fixture(html `<nile-calendar variant="date-time-picker"></nile-calendar>`);
|
|
904
|
+
const realDays = (root) => root.shadowRoot.querySelectorAll('.day_date:not(.filler)');
|
|
905
|
+
const clickDay = (root, i = 5) => realDays(root)[i].click();
|
|
906
|
+
// capture BOTH the canonical and the deprecated event in one go
|
|
907
|
+
const listen = (el) => {
|
|
908
|
+
const seen = { changeCount: 0 };
|
|
909
|
+
el.addEventListener('nile-change', ((e) => { seen.change = e.detail; seen.changeCount += 1; }));
|
|
910
|
+
el.addEventListener('nile-changed', ((e) => { seen.changed = e.detail; }));
|
|
911
|
+
return seen;
|
|
912
|
+
};
|
|
913
|
+
// ── default calendar baseline (the contract the variant must not break) ──────
|
|
914
|
+
it('P1. default single emits nile-change + nile-changed with exactly { value: Date }', async () => {
|
|
915
|
+
const el = await defaultSingle();
|
|
916
|
+
const seen = listen(el);
|
|
917
|
+
clickDay(el, 5);
|
|
918
|
+
await el.updateComplete;
|
|
919
|
+
expect(seen.change).to.not.be.undefined;
|
|
920
|
+
expect(seen.change.value).to.be.instanceOf(Date);
|
|
921
|
+
// deprecated alias fires with the identical detail
|
|
922
|
+
expect(seen.changed).to.equal(seen.change);
|
|
923
|
+
// no extra keys leaked onto the default payload
|
|
924
|
+
expect(Object.keys(seen.change)).to.deep.equal(['value']);
|
|
925
|
+
expect(seen.change.timezone).to.be.undefined;
|
|
926
|
+
});
|
|
927
|
+
it('P2. default single emits immediately on day click (no Apply button exists)', async () => {
|
|
928
|
+
const el = await defaultSingle();
|
|
929
|
+
expect(el.shadowRoot.querySelector('.apply-button')).to.be.null;
|
|
930
|
+
const seen = listen(el);
|
|
931
|
+
clickDay(el, 3);
|
|
932
|
+
await el.updateComplete;
|
|
933
|
+
expect(seen.changeCount).to.equal(1);
|
|
934
|
+
});
|
|
935
|
+
it('P3. default range emits { startDate, endDate } on Apply — no timezone key', async () => {
|
|
936
|
+
const el = await defaultRange();
|
|
937
|
+
clickDay(el, 3);
|
|
938
|
+
await el.updateComplete;
|
|
939
|
+
clickDay(el, 9);
|
|
940
|
+
await el.updateComplete;
|
|
941
|
+
const seen = listen(el);
|
|
942
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
943
|
+
expect(seen.change.startDate).to.be.instanceOf(Date);
|
|
944
|
+
expect(seen.change.endDate).to.be.instanceOf(Date);
|
|
945
|
+
expect(seen.changed).to.equal(seen.change);
|
|
946
|
+
expect(Object.keys(seen.change).sort()).to.deep.equal(['endDate', 'startDate']);
|
|
947
|
+
expect(seen.change.timezone).to.be.undefined;
|
|
948
|
+
});
|
|
949
|
+
// ── variant emits the default payload (superset) + its own timezone ──────────
|
|
950
|
+
it('P4. variant absolute payload is a SUPERSET of the default single payload', async () => {
|
|
951
|
+
// default baseline
|
|
952
|
+
const def = await defaultSingle();
|
|
953
|
+
const defSeen = listen(def);
|
|
954
|
+
clickDay(def, 5);
|
|
955
|
+
await def.updateComplete;
|
|
956
|
+
// variant
|
|
957
|
+
const el = await variant();
|
|
958
|
+
clickDay(el, 5);
|
|
959
|
+
await el.updateComplete;
|
|
960
|
+
const seen = listen(el);
|
|
961
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
962
|
+
// the default emits a Date under .value; the variant emits a zone-encoded
|
|
963
|
+
// string under the same key (carries the selected timezone)
|
|
964
|
+
expect(defSeen.change.value).to.be.instanceOf(Date);
|
|
965
|
+
expect(seen.change.value).to.be.a('string');
|
|
966
|
+
// every key the default emits is still present on the variant payload
|
|
967
|
+
Object.keys(defSeen.change).forEach(k => expect(seen.change).to.have.property(k));
|
|
968
|
+
// ...plus its own timezone
|
|
969
|
+
expect(seen.change.timezone).to.equal('UTC');
|
|
970
|
+
// the deprecated alias is dropped for the variant
|
|
971
|
+
expect(seen.changed).to.be.undefined;
|
|
972
|
+
});
|
|
973
|
+
it('P5. variant relative payload is a SUPERSET of the default range payload', async () => {
|
|
974
|
+
// default range baseline keys
|
|
975
|
+
const def = await defaultRange();
|
|
976
|
+
clickDay(def, 3);
|
|
977
|
+
await def.updateComplete;
|
|
978
|
+
clickDay(def, 9);
|
|
979
|
+
await def.updateComplete;
|
|
980
|
+
const defSeen = listen(def);
|
|
981
|
+
def.shadowRoot.querySelector('.apply-button').click();
|
|
982
|
+
// variant relative
|
|
983
|
+
const el = await variant();
|
|
984
|
+
el.type = 'relative';
|
|
985
|
+
await el.updateComplete;
|
|
986
|
+
el.shadowRoot.querySelector('.duration__value').click();
|
|
987
|
+
await el.updateComplete;
|
|
988
|
+
const seen = listen(el);
|
|
989
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
990
|
+
expect(seen.change.startDate).to.be.instanceOf(Date);
|
|
991
|
+
expect(seen.change.endDate).to.be.instanceOf(Date);
|
|
992
|
+
Object.keys(defSeen.change).forEach(k => expect(seen.change).to.have.property(k));
|
|
993
|
+
expect(seen.change.timezone).to.equal('UTC');
|
|
994
|
+
expect(seen.changed).to.be.undefined;
|
|
995
|
+
});
|
|
996
|
+
// ── value equality: the SAME picked DATE produces the SAME payload value ─────
|
|
997
|
+
// The two render different day grids, so match by day-of-month number rather than by cell index.
|
|
998
|
+
const clickDayNum = (root, n) => {
|
|
999
|
+
const cell = [...realDays(root)].find(d => d.textContent.trim() === String(n));
|
|
1000
|
+
if (!cell)
|
|
1001
|
+
throw new Error(`day ${n} not found in current month`);
|
|
1002
|
+
cell.click();
|
|
1003
|
+
};
|
|
1004
|
+
// The variant renders a collapsed 21-day window anchored on "today", so a hard-coded day of month is only on the grid
|
|
1005
|
+
// for part of each month. These tests freeze `new Date()` (the component does not go through `Date.now`) at local
|
|
1006
|
+
// noon on 2026-06-15, a Monday whose window (Jun 7-27) always contains day 15, in every host timezone.
|
|
1007
|
+
const withFrozenToday = async (fn) => {
|
|
1008
|
+
const RealDate = Date;
|
|
1009
|
+
const FROZEN = new RealDate(2026, 5, 15, 12, 0, 0, 0).getTime();
|
|
1010
|
+
class FrozenDate extends RealDate {
|
|
1011
|
+
constructor(...args) {
|
|
1012
|
+
if (args.length === 0)
|
|
1013
|
+
super(FROZEN);
|
|
1014
|
+
else
|
|
1015
|
+
super(...args);
|
|
1016
|
+
}
|
|
1017
|
+
static now() { return FROZEN; }
|
|
1018
|
+
}
|
|
1019
|
+
globalThis.Date = FrozenDate;
|
|
1020
|
+
try {
|
|
1021
|
+
return await fn();
|
|
1022
|
+
}
|
|
1023
|
+
finally {
|
|
1024
|
+
globalThis.Date = RealDate;
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
it('P5a. single — same picked date with no time entered: variant `value` string names the same wall-clock day as the default Date', () => withFrozenToday(async () => {
|
|
1028
|
+
// default: emits on click, browser-local midnight (a Date)
|
|
1029
|
+
const def = await defaultSingle();
|
|
1030
|
+
const defSeen = listen(def);
|
|
1031
|
+
clickDayNum(def, 15);
|
|
1032
|
+
await def.updateComplete;
|
|
1033
|
+
// variant `value` is a zone-encoded string carrying the picked wall-clock
|
|
1034
|
+
// as-entered — its date/time portion names the same calendar day at midnight.
|
|
1035
|
+
const el = await variant();
|
|
1036
|
+
const seen = listen(el);
|
|
1037
|
+
clickDayNum(el, 15);
|
|
1038
|
+
await el.updateComplete;
|
|
1039
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
1040
|
+
const d = defSeen.change.value;
|
|
1041
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
1042
|
+
const wallClock = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T00:00:00.000`;
|
|
1043
|
+
expect(seen.change.value).to.be.a('string');
|
|
1044
|
+
expect(seen.change.value).to.include(wallClock);
|
|
1045
|
+
}));
|
|
1046
|
+
it('P5b. single — entering a time only layers time-of-day on the SAME calendar day', () => withFrozenToday(async () => {
|
|
1047
|
+
const def = await defaultSingle();
|
|
1048
|
+
const defSeen = listen(def);
|
|
1049
|
+
clickDayNum(def, 15);
|
|
1050
|
+
await def.updateComplete;
|
|
1051
|
+
const el = await variant();
|
|
1052
|
+
const seen = listen(el);
|
|
1053
|
+
clickDayNum(el, 15);
|
|
1054
|
+
await el.updateComplete;
|
|
1055
|
+
// enter 09:30:15 before applying
|
|
1056
|
+
el.tpHours = 9;
|
|
1057
|
+
el.tpMinutes = 30;
|
|
1058
|
+
el.tpSeconds = 15;
|
|
1059
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
1060
|
+
const d = defSeen.change.value, v = seen.change.value;
|
|
1061
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
1062
|
+
// the date the old calendar resolves is unchanged — only the clock moves
|
|
1063
|
+
const day = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
1064
|
+
expect(v).to.include(`${day}T09:30:15`);
|
|
1065
|
+
}));
|
|
1066
|
+
it('P5d. single — localTimezoneValue honors the selected timezone (same wall-clock in two zones differs by the zone offset)', () => withFrozenToday(async () => {
|
|
1067
|
+
const pickMidnight = async (tz) => {
|
|
1068
|
+
const el = await variant();
|
|
1069
|
+
el.timezone = tz;
|
|
1070
|
+
const seen = listen(el);
|
|
1071
|
+
clickDayNum(el, 15);
|
|
1072
|
+
await el.updateComplete;
|
|
1073
|
+
el.tpHours = 0;
|
|
1074
|
+
el.tpMinutes = 0;
|
|
1075
|
+
el.tpSeconds = 0;
|
|
1076
|
+
el.tpMilliseconds = 0;
|
|
1077
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
1078
|
+
return seen.change;
|
|
1079
|
+
};
|
|
1080
|
+
const utc = await pickMidnight('UTC');
|
|
1081
|
+
const kolkata = await pickMidnight('Asia/Kolkata'); // UTC+5:30, no DST
|
|
1082
|
+
// `value` shows the entered wall-clock (00:00) regardless of zone, and each
|
|
1083
|
+
// carries its own selected zone name
|
|
1084
|
+
expect(utc.value).to.include('T00:00:00');
|
|
1085
|
+
expect(kolkata.value).to.include('T00:00:00');
|
|
1086
|
+
expect(utc.value).to.include('[GMT+00:00 UTC]');
|
|
1087
|
+
expect(kolkata.value).to.include('[GMT+05:30 Asia/Kolkata]');
|
|
1088
|
+
// `localTimezoneValue` is the absolute instant: midnight UTC reads as 00:00Z,
|
|
1089
|
+
// and midnight Kolkata is 5h30m earlier in absolute terms than midnight UTC.
|
|
1090
|
+
expect(utc.localTimezoneValue.getUTCHours()).to.equal(0);
|
|
1091
|
+
expect(utc.localTimezoneValue.getTime() - kolkata.localTimezoneValue.getTime())
|
|
1092
|
+
.to.equal((5 * 60 + 30) * 60 * 1000);
|
|
1093
|
+
}));
|
|
1094
|
+
it('P5c. range — default emits an ordered, distinct { startDate, endDate } and the variant range stays ordered too', async () => {
|
|
1095
|
+
// default range: two day clicks → ordered pair (the old contract)
|
|
1096
|
+
const def = await defaultRange();
|
|
1097
|
+
clickDay(def, 3);
|
|
1098
|
+
await def.updateComplete;
|
|
1099
|
+
clickDay(def, 9);
|
|
1100
|
+
await def.updateComplete;
|
|
1101
|
+
const defSeen = listen(def);
|
|
1102
|
+
def.shadowRoot.querySelector('.apply-button').click();
|
|
1103
|
+
expect(defSeen.change.startDate.getTime()).to.be.lessThan(defSeen.change.endDate.getTime());
|
|
1104
|
+
// variant range (relative tab) → still an ordered Date pair
|
|
1105
|
+
const el = await variant();
|
|
1106
|
+
el.type = 'relative';
|
|
1107
|
+
await el.updateComplete;
|
|
1108
|
+
el.shadowRoot.querySelector('.duration__value').click();
|
|
1109
|
+
await el.updateComplete;
|
|
1110
|
+
const seen = listen(el);
|
|
1111
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
1112
|
+
expect(seen.change.startDate.getTime()).to.be.at.most(seen.change.endDate.getTime());
|
|
1113
|
+
});
|
|
1114
|
+
it('P6. variant fires nile-change only; the deprecated nile-changed is dropped for the variant', async () => {
|
|
1115
|
+
const el = await variant();
|
|
1116
|
+
clickDay(el, 5);
|
|
1117
|
+
await el.updateComplete;
|
|
1118
|
+
let change = false, changed = false;
|
|
1119
|
+
el.addEventListener('nile-change', () => { change = true; });
|
|
1120
|
+
el.addEventListener('nile-changed', () => { changed = true; });
|
|
1121
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
1122
|
+
expect(change).to.be.true;
|
|
1123
|
+
expect(changed).to.be.false;
|
|
1124
|
+
});
|
|
1125
|
+
// ── zero breakage: the default render/behaviour is untouched by the variant ──
|
|
1126
|
+
it('P7. variant="default" renders identically to no variant (no picker panel)', async () => {
|
|
1127
|
+
const implicit = await defaultSingle();
|
|
1128
|
+
const explicit = await fixture(html `<nile-calendar variant="default"></nile-calendar>`);
|
|
1129
|
+
for (const el of [implicit, explicit]) {
|
|
1130
|
+
expect(el.shadowRoot.querySelector('.base')).to.exist;
|
|
1131
|
+
expect(el.shadowRoot.querySelector('.base--date-time-picker')).to.be.null;
|
|
1132
|
+
expect(el.shadowRoot.querySelector('.tp-footer')).to.be.null;
|
|
1133
|
+
expect(el.shadowRoot.querySelector('.tp-time-fields')).to.be.null;
|
|
1134
|
+
expect(el.shadowRoot.querySelector('.tp-chevron')).to.be.null;
|
|
1135
|
+
expect(el.shadowRoot.querySelector('.tp-timezone')).to.be.null;
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
it('P8. default calendar exposes no timezone-related public state', async () => {
|
|
1139
|
+
const el = await defaultSingle();
|
|
1140
|
+
// timezone defaults to UTC as a property but the default UI never surfaces it
|
|
1141
|
+
expect(el.shadowRoot.querySelector('.tp-timezone-select')).to.be.null;
|
|
1142
|
+
expect(el.shadowRoot.querySelector('.tp-set-to-now-button')).to.be.null;
|
|
1143
|
+
});
|
|
1144
|
+
it('P9. default single fires exactly one nile-change per click — variant batches until Apply', async () => {
|
|
1145
|
+
const def = await defaultSingle();
|
|
1146
|
+
const defSeen = listen(def);
|
|
1147
|
+
clickDay(def, 5);
|
|
1148
|
+
await def.updateComplete;
|
|
1149
|
+
expect(defSeen.changeCount).to.equal(1);
|
|
1150
|
+
const el = await variant();
|
|
1151
|
+
const seen = listen(el);
|
|
1152
|
+
clickDay(el, 5);
|
|
1153
|
+
await el.updateComplete;
|
|
1154
|
+
expect(seen.changeCount).to.equal(0); // nothing emitted until Apply
|
|
1155
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
1156
|
+
expect(seen.changeCount).to.equal(1);
|
|
1157
|
+
});
|
|
1158
|
+
it('P10. both variants emit nile-clear on reset/clear (same clear contract)', async () => {
|
|
1159
|
+
// default range reset
|
|
1160
|
+
const def = await defaultRange();
|
|
1161
|
+
clickDay(def, 3);
|
|
1162
|
+
await def.updateComplete;
|
|
1163
|
+
clickDay(def, 9);
|
|
1164
|
+
await def.updateComplete;
|
|
1165
|
+
let defCleared = false;
|
|
1166
|
+
def.addEventListener('nile-clear', () => { defCleared = true; });
|
|
1167
|
+
def.clearDate();
|
|
1168
|
+
expect(defCleared).to.be.true;
|
|
1169
|
+
// variant reset
|
|
1170
|
+
const el = await variant();
|
|
1171
|
+
clickDay(el, 5);
|
|
1172
|
+
await el.updateComplete;
|
|
1173
|
+
let cleared = false;
|
|
1174
|
+
el.addEventListener('nile-clear', () => { cleared = true; });
|
|
1175
|
+
el.shadowRoot.querySelector('.clear-button').click();
|
|
1176
|
+
await el.updateComplete;
|
|
1177
|
+
expect(cleared).to.be.true;
|
|
1178
|
+
});
|
|
1179
|
+
it('P11. switching variant at runtime back to default restores the default render', async () => {
|
|
1180
|
+
const el = await variant();
|
|
1181
|
+
expect(el.shadowRoot.querySelector('.base--date-time-picker')).to.exist;
|
|
1182
|
+
el.variant = 'default';
|
|
1183
|
+
await el.updateComplete;
|
|
1184
|
+
expect(el.shadowRoot.querySelector('.base--date-time-picker')).to.be.null;
|
|
1185
|
+
expect(el.shadowRoot.querySelector('.tp-footer')).to.be.null;
|
|
1186
|
+
expect(el.shadowRoot.querySelector('.base')).to.exist;
|
|
1187
|
+
});
|
|
1188
|
+
// tab support differs by variant in SINGLE mode
|
|
1189
|
+
it('P12. both variants hide the Absolute/Relative tabs until asked; showTypes reveals them', async () => {
|
|
1190
|
+
// default single: the switcher is hidden (tabs are range-only)
|
|
1191
|
+
const def = await defaultSingle();
|
|
1192
|
+
const defConfig = def.shadowRoot.querySelector('.calendar-config');
|
|
1193
|
+
expect(defConfig, '.calendar-config should render').to.exist;
|
|
1194
|
+
expect(defConfig.classList.contains('hidden')).to.be.true;
|
|
1195
|
+
// variant single: hidden by default too -- showTypes is opt-in
|
|
1196
|
+
const el = await variant();
|
|
1197
|
+
expect(el.showTypes).to.be.false;
|
|
1198
|
+
expect(el.shadowRoot.querySelector('.calendar-config').classList.contains('hidden')).to.be.true;
|
|
1199
|
+
// ...and setting it reveals the tabs
|
|
1200
|
+
el.showTypes = true;
|
|
1201
|
+
await el.updateComplete;
|
|
1202
|
+
expect(el.shadowRoot.querySelector('.calendar-config').classList.contains('hidden')).to.be.false;
|
|
1203
|
+
expect(el.shadowRoot.querySelector('nile-tab[panel="absolute"]')).to.exist;
|
|
1204
|
+
expect(el.shadowRoot.querySelector('nile-tab[panel="relative"]')).to.exist;
|
|
1205
|
+
});
|
|
1206
|
+
it('P13. variant single emits the absolute payload on Apply, then the relative payload after switching tabs', () => withFrozenToday(async () => {
|
|
1207
|
+
const el = await variant();
|
|
1208
|
+
const seen = listen(el);
|
|
1209
|
+
// Absolute tab (default): pick a day, Apply -> { value, localTimezoneValue, timezone }
|
|
1210
|
+
clickDayNum(el, 15);
|
|
1211
|
+
await el.updateComplete;
|
|
1212
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
1213
|
+
expect(seen.change.value).to.be.a('string');
|
|
1214
|
+
expect(seen.change.localTimezoneValue).to.be.instanceOf(Date);
|
|
1215
|
+
expect(seen.change).to.not.have.property('startDate');
|
|
1216
|
+
// Relative tab: pick a duration, Apply -> { startDate, endDate, timezone }
|
|
1217
|
+
el.type = 'relative';
|
|
1218
|
+
await el.updateComplete;
|
|
1219
|
+
el.shadowRoot.querySelector('.duration__value').click();
|
|
1220
|
+
await el.updateComplete;
|
|
1221
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
1222
|
+
expect(seen.change.startDate).to.be.instanceOf(Date);
|
|
1223
|
+
expect(seen.change.endDate).to.be.instanceOf(Date);
|
|
1224
|
+
expect(seen.change.timezone).to.exist;
|
|
1225
|
+
}));
|
|
1226
|
+
it('P14. relative payload timezone is always UTC, even after picking a non-UTC zone on Absolute', async () => {
|
|
1227
|
+
const el = await variant();
|
|
1228
|
+
el.timezone = 'Asia/Kolkata'; // as if a zone was chosen on the Absolute tab
|
|
1229
|
+
await el.updateComplete;
|
|
1230
|
+
el.type = 'relative';
|
|
1231
|
+
await el.updateComplete;
|
|
1232
|
+
const seen = listen(el);
|
|
1233
|
+
el.shadowRoot.querySelector('.duration__value').click();
|
|
1234
|
+
await el.updateComplete;
|
|
1235
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
1236
|
+
expect(seen.change.timezone).to.equal('UTC');
|
|
1237
|
+
});
|
|
1238
|
+
it('P15. variant relative shows the manual Duration/Unit input without range; non-range default does not', async () => {
|
|
1239
|
+
// variant (no range): the relative tab exposes the manual Duration input
|
|
1240
|
+
const el = await variant();
|
|
1241
|
+
el.type = 'relative';
|
|
1242
|
+
await el.updateComplete;
|
|
1243
|
+
expect(el.shadowRoot.querySelector('.duration-input')).to.exist;
|
|
1244
|
+
// default, no range: the manual Duration input stays hidden (range-only)
|
|
1245
|
+
const def = await defaultSingle();
|
|
1246
|
+
def.type = 'relative';
|
|
1247
|
+
await def.updateComplete;
|
|
1248
|
+
expect(def.shadowRoot.querySelector('.duration-input')).to.be.null;
|
|
1249
|
+
});
|
|
1250
|
+
});
|
|
1251
|
+
/** structuredInput — masked/structured entry for the in-panel manual inputs. Only exercised in range
|
|
1252
|
+
* mode with showManualInputs; when false the inputs remain free-form. */
|
|
1253
|
+
describe('NileCalendar structuredInput masked manual entry', () => {
|
|
1254
|
+
const rangeFixture = (dateFormat = 'MM/DD/YYYY', structured = true) => fixture(html `
|
|
1255
|
+
<nile-calendar
|
|
1256
|
+
range
|
|
1257
|
+
.showManualInputs="${true}"
|
|
1258
|
+
.structuredInput="${structured}"
|
|
1259
|
+
dateFormat="${dateFormat}"
|
|
1260
|
+
></nile-calendar>
|
|
1261
|
+
`);
|
|
1262
|
+
const nativeOf = async (el, selector) => {
|
|
1263
|
+
const nileInput = el.shadowRoot.querySelector(selector);
|
|
1264
|
+
await nileInput.updateComplete;
|
|
1265
|
+
return { nileInput, native: nileInput.input };
|
|
1266
|
+
};
|
|
1267
|
+
const typeDigits = async (el, native, keys) => {
|
|
1268
|
+
for (const key of keys) {
|
|
1269
|
+
native.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true }));
|
|
1270
|
+
}
|
|
1271
|
+
await el.updateComplete;
|
|
1272
|
+
};
|
|
1273
|
+
it('S1. structuredInput defaults to false', async () => {
|
|
1274
|
+
const el = await fixture(html `<nile-calendar></nile-calendar>`);
|
|
1275
|
+
expect(el.structuredInput).to.be.false;
|
|
1276
|
+
});
|
|
1277
|
+
it('S2. with structuredInput false the manual date input is free-form (no key blocking)', async () => {
|
|
1278
|
+
const el = await rangeFixture('MM/DD/YYYY', false);
|
|
1279
|
+
const { native } = await nativeOf(el, '.calender-input .date-input');
|
|
1280
|
+
// a non-digit keystroke is NOT prevented (no mask interception)
|
|
1281
|
+
const ev = new KeyboardEvent('keydown', { key: 'a', bubbles: true, composed: true, cancelable: true });
|
|
1282
|
+
native.dispatchEvent(ev);
|
|
1283
|
+
expect(ev.defaultPrevented).to.be.false;
|
|
1284
|
+
// free-form text stays exactly as written
|
|
1285
|
+
native.value = 'free/text';
|
|
1286
|
+
expect(native.value).to.equal('free/text');
|
|
1287
|
+
});
|
|
1288
|
+
it('S3. masked date input auto-inserts dateFormat separators (DD/MM/YYYY)', async () => {
|
|
1289
|
+
const el = await rangeFixture('DD/MM/YYYY', true);
|
|
1290
|
+
const { native } = await nativeOf(el, '.calender-input .date-input');
|
|
1291
|
+
await typeDigits(el, native, '15062026');
|
|
1292
|
+
expect(native.value).to.equal('15/06/2026');
|
|
1293
|
+
});
|
|
1294
|
+
it('S4. masked date input follows a different dateFormat (MM/DD/YYYY)', async () => {
|
|
1295
|
+
const el = await rangeFixture('MM/DD/YYYY', true);
|
|
1296
|
+
const { native } = await nativeOf(el, '.calender-input .date-input');
|
|
1297
|
+
await typeDigits(el, native, '06152026');
|
|
1298
|
+
expect(native.value).to.equal('06/15/2026');
|
|
1299
|
+
});
|
|
1300
|
+
it('S5. out-of-range hour digit is clamped (99 -> 09)', async () => {
|
|
1301
|
+
const el = await rangeFixture('MM/DD/YYYY', true);
|
|
1302
|
+
const { native } = await nativeOf(el, '.calender-input .time-input');
|
|
1303
|
+
await typeDigits(el, native, '99');
|
|
1304
|
+
// 99 > 23 -> keeps the first digit -> "09" (with the auto separator)
|
|
1305
|
+
expect(native.value.startsWith('09')).to.be.true;
|
|
1306
|
+
expect(native.value).to.not.contain('99');
|
|
1307
|
+
});
|
|
1308
|
+
it('S6. out-of-range month digit is clamped (13 -> 01)', async () => {
|
|
1309
|
+
const el = await rangeFixture('MM/DD/YYYY', true);
|
|
1310
|
+
const { native } = await nativeOf(el, '.calender-input .date-input');
|
|
1311
|
+
await typeDigits(el, native, '13');
|
|
1312
|
+
// 13 > 12 -> keeps the first digit -> "01"
|
|
1313
|
+
expect(native.value.startsWith('01')).to.be.true;
|
|
1314
|
+
});
|
|
1315
|
+
it('S7. typing beyond the mask length is ignored', async () => {
|
|
1316
|
+
const el = await rangeFixture('DD/MM/YYYY', true);
|
|
1317
|
+
const { native } = await nativeOf(el, '.calender-input .date-input');
|
|
1318
|
+
await typeDigits(el, native, '150620269'); // one extra digit
|
|
1319
|
+
expect(native.value).to.equal('15/06/2026');
|
|
1320
|
+
});
|
|
1321
|
+
it('S8. a completed masked date commits through the start-date handler', async () => {
|
|
1322
|
+
const el = await rangeFixture('DD/MM/YYYY', true);
|
|
1323
|
+
const { nileInput, native } = await nativeOf(el, '.calender-input .date-input');
|
|
1324
|
+
await typeDigits(el, native, '15062026');
|
|
1325
|
+
// blur commits the value into the calendar via nile-change
|
|
1326
|
+
nileInput.dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true }));
|
|
1327
|
+
await el.updateComplete;
|
|
1328
|
+
expect(el.startDate).to.be.instanceOf(Date);
|
|
1329
|
+
expect(el.startDate.getFullYear()).to.equal(2026);
|
|
1330
|
+
expect(el.startDate.getMonth()).to.equal(5); // June (0-based)
|
|
1331
|
+
expect(el.startDate.getDate()).to.equal(15);
|
|
1332
|
+
});
|
|
1333
|
+
it('S9. default variant keeps its legacy rollover for an impossible day (31/02/2026 -> Mar 3)', async () => {
|
|
1334
|
+
// Characterization only: the invalid-day clamp is scoped to the date-time-picker variant, so the
|
|
1335
|
+
// default variant's parseDate path must stay exactly as it was.
|
|
1336
|
+
const el = await rangeFixture('DD/MM/YYYY', true);
|
|
1337
|
+
const { nileInput, native } = await nativeOf(el, '.calender-input .date-input');
|
|
1338
|
+
await typeDigits(el, native, '31022026');
|
|
1339
|
+
expect(native.value).to.equal('31/02/2026');
|
|
1340
|
+
nileInput.dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true }));
|
|
1341
|
+
await el.updateComplete;
|
|
1342
|
+
expect(el.startDate.getFullYear()).to.equal(2026);
|
|
1343
|
+
expect(el.startDate.getMonth()).to.equal(2); // March (0-based)
|
|
1344
|
+
expect(el.startDate.getDate()).to.equal(3);
|
|
1345
|
+
});
|
|
1346
|
+
});
|
|
1347
|
+
/** Preset `value` seeding must land on the SAME calendar day it names in every browser timezone, and
|
|
1348
|
+
* Apply must emit that exact day/time. Run under `TZ=America/New_York` to exercise the off-by-one path. */
|
|
1349
|
+
describe('NileCalendar preset value seeding + emitted payload', () => {
|
|
1350
|
+
const dtpPreset = (value) => fixture(html `<nile-calendar variant="date-time-picker" value="${value}"></nile-calendar>`);
|
|
1351
|
+
const apply = (el) => el.shadowRoot.querySelector('.apply-button').click();
|
|
1352
|
+
it('PS1. preset date-only value → Apply (no edits) emits that exact day at midnight (no off-by-one)', async () => {
|
|
1353
|
+
const el = await dtpPreset('2026-06-15');
|
|
1354
|
+
await el.updateComplete;
|
|
1355
|
+
let detail = null;
|
|
1356
|
+
el.addEventListener('nile-change', ((e) => { detail = e.detail; }));
|
|
1357
|
+
apply(el);
|
|
1358
|
+
expect(detail, 'Apply must emit even without a manual edit').to.not.be.null;
|
|
1359
|
+
expect(detail.value).to.be.a('string');
|
|
1360
|
+
// the day the preset NAMES — never shifted by the UTC boundary — at midnight
|
|
1361
|
+
expect(detail.value).to.match(/^2026-06-15T00:00:00\.000\[GMT[+-]\d{2}:\d{2} .+\]$/);
|
|
1362
|
+
});
|
|
1363
|
+
it('PS2. preset date-time value seeds the time spinners and Apply emits that wall-clock', async () => {
|
|
1364
|
+
const el = await dtpPreset('2026-06-15T09:30:45'); // zoneless → local wall-clock
|
|
1365
|
+
await el.updateComplete;
|
|
1366
|
+
expect(el.tpHours).to.equal(9);
|
|
1367
|
+
expect(el.tpMinutes).to.equal(30);
|
|
1368
|
+
expect(el.tpSeconds).to.equal(45);
|
|
1369
|
+
let detail = null;
|
|
1370
|
+
el.addEventListener('nile-change', ((e) => { detail = e.detail; }));
|
|
1371
|
+
apply(el);
|
|
1372
|
+
expect(detail.value).to.include('2026-06-15T09:30:45');
|
|
1373
|
+
});
|
|
1374
|
+
it('PS3. seeding a preset value is SILENT — it must NOT emit nile-change on its own', async () => {
|
|
1375
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker"></nile-calendar>`);
|
|
1376
|
+
await el.updateComplete;
|
|
1377
|
+
let emitted = false;
|
|
1378
|
+
el.addEventListener('nile-change', () => { emitted = true; });
|
|
1379
|
+
el.setAttribute('value', '2026-06-15');
|
|
1380
|
+
await el.updateComplete;
|
|
1381
|
+
expect(emitted, 'presetting a value should not fire a change event').to.be.false;
|
|
1382
|
+
});
|
|
1383
|
+
it('PS4. changing timezone after a preset keeps the wall-clock day and emits the new zone on Apply', async () => {
|
|
1384
|
+
const el = await dtpPreset('2026-06-15');
|
|
1385
|
+
await el.updateComplete;
|
|
1386
|
+
el.timezone = 'Asia/Kolkata';
|
|
1387
|
+
await el.updateComplete;
|
|
1388
|
+
let detail = null;
|
|
1389
|
+
el.addEventListener('nile-change', ((e) => { detail = e.detail; }));
|
|
1390
|
+
apply(el);
|
|
1391
|
+
expect(detail.value).to.include('2026-06-15'); // wall-clock day is unchanged
|
|
1392
|
+
expect(detail.value).to.include('[GMT+05:30 Asia/Kolkata]');
|
|
1393
|
+
expect(detail.timezone).to.equal('Asia/Kolkata');
|
|
1394
|
+
expect(detail.localTimezoneValue).to.be.instanceOf(Date);
|
|
1395
|
+
});
|
|
1396
|
+
it('PS5. default (non-picker) single preset is seeded exactly as published (host-offset shifted, not the written day on every host)', async () => {
|
|
1397
|
+
const el = await fixture(html `<nile-calendar value="2026-06-15"></nile-calendar>`);
|
|
1398
|
+
await el.updateComplete;
|
|
1399
|
+
expect(el.value).to.be.instanceOf(Date);
|
|
1400
|
+
expect(el.value.getTime()).to.equal(published.seed('2026-06-15').getTime());
|
|
1401
|
+
});
|
|
1402
|
+
it('PS6. an invalid preset value leaves the calendar unselected (no bogus emit)', async () => {
|
|
1403
|
+
const el = await dtpPreset('not-a-date');
|
|
1404
|
+
await el.updateComplete;
|
|
1405
|
+
expect(el.value == null).to.be.true;
|
|
1406
|
+
});
|
|
1407
|
+
});
|
|
1408
|
+
describe('NileCalendar structuredInput listener lifecycle (#2b)', () => {
|
|
1409
|
+
const rangeFixture = (structured) => fixture(html `
|
|
1410
|
+
<nile-calendar
|
|
1411
|
+
range
|
|
1412
|
+
.showManualInputs="${true}"
|
|
1413
|
+
.structuredInput="${structured}"
|
|
1414
|
+
dateFormat="MM/DD/YYYY"
|
|
1415
|
+
></nile-calendar>
|
|
1416
|
+
`);
|
|
1417
|
+
const moveInDom = async (el) => {
|
|
1418
|
+
const host = el.parentNode;
|
|
1419
|
+
el.remove();
|
|
1420
|
+
host.appendChild(el);
|
|
1421
|
+
await el.updateComplete;
|
|
1422
|
+
};
|
|
1423
|
+
const firstNative = async (el) => {
|
|
1424
|
+
const nileInput = el.shadowRoot.querySelector('.calender-input .date-input');
|
|
1425
|
+
await nileInput.updateComplete;
|
|
1426
|
+
return nileInput.input;
|
|
1427
|
+
};
|
|
1428
|
+
const keydown = (native, key) => {
|
|
1429
|
+
const ev = new KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true });
|
|
1430
|
+
native.dispatchEvent(ev);
|
|
1431
|
+
return ev;
|
|
1432
|
+
};
|
|
1433
|
+
it('L1. the date mask still applies after the calendar is moved in the DOM', async () => {
|
|
1434
|
+
const el = await rangeFixture(true);
|
|
1435
|
+
await moveInDom(el);
|
|
1436
|
+
const native = await firstNative(el);
|
|
1437
|
+
for (const key of '03152026')
|
|
1438
|
+
keydown(native, key);
|
|
1439
|
+
await el.updateComplete;
|
|
1440
|
+
expect(native.value).to.equal('03/15/2026');
|
|
1441
|
+
});
|
|
1442
|
+
it('L2. without structuredInput a moved calendar stays free-form (no listeners added)', async () => {
|
|
1443
|
+
const el = await rangeFixture(false);
|
|
1444
|
+
await moveInDom(el);
|
|
1445
|
+
const native = await firstNative(el);
|
|
1446
|
+
expect(keydown(native, 'a').defaultPrevented).to.be.false;
|
|
1447
|
+
});
|
|
1448
|
+
});
|
|
1449
|
+
describe('NileCalendar time stepper focus lifecycle', () => {
|
|
1450
|
+
const dtp = () => fixture(html `<nile-calendar variant="date-time-picker" timezone="UTC"></nile-calendar>`);
|
|
1451
|
+
const stepperBtns = (el) => Array.from(el.shadowRoot.querySelectorAll('.tp-stepper-btn'));
|
|
1452
|
+
it('S1. in a rendered calendar, a stepper click steps the value AND moves focus to that field', async () => {
|
|
1453
|
+
const el = await dtp();
|
|
1454
|
+
el.tpHours = 9;
|
|
1455
|
+
await el.updateComplete;
|
|
1456
|
+
const up = stepperBtns(el)[0];
|
|
1457
|
+
const nileInput = up.closest('nile-input');
|
|
1458
|
+
await nileInput.updateComplete;
|
|
1459
|
+
expect(nileInput.input, 'inner input rendered').to.exist;
|
|
1460
|
+
up.click();
|
|
1461
|
+
await el.updateComplete;
|
|
1462
|
+
expect(el.tpHours).to.equal(10);
|
|
1463
|
+
expect(nileInput.shadowRoot.activeElement, 'focus moved to the stepped field').to.equal(nileInput.input);
|
|
1464
|
+
});
|
|
1465
|
+
it('S2. a stepper whose nile-input has not rendered (no inner input) steps without throwing', async () => {
|
|
1466
|
+
const el = await dtp();
|
|
1467
|
+
el.tpHours = 9;
|
|
1468
|
+
await el.updateComplete;
|
|
1469
|
+
const up = stepperBtns(el)[0];
|
|
1470
|
+
const nileInput = up.closest('nile-input');
|
|
1471
|
+
await nileInput.updateComplete;
|
|
1472
|
+
// simulate an input that never rendered (calendar parked in a closed floating panel)
|
|
1473
|
+
Object.defineProperty(nileInput, 'input', { value: null, configurable: true });
|
|
1474
|
+
const errors = [];
|
|
1475
|
+
const onError = (e) => { errors.push(e.message); e.preventDefault(); };
|
|
1476
|
+
window.addEventListener('error', onError);
|
|
1477
|
+
try {
|
|
1478
|
+
up.click();
|
|
1479
|
+
await el.updateComplete;
|
|
1480
|
+
}
|
|
1481
|
+
finally {
|
|
1482
|
+
window.removeEventListener('error', onError);
|
|
1483
|
+
}
|
|
1484
|
+
expect(errors, 'no uncaught error from the click handler').to.deep.equal([]);
|
|
1485
|
+
expect(el.tpHours).to.equal(10);
|
|
1486
|
+
});
|
|
1487
|
+
});
|
|
1488
|
+
/**
|
|
1489
|
+
* ── Z. YEARS 0-99 in the calendar grid, selection, allowedDates and typed dates ─────────────────────
|
|
1490
|
+
* `new Date(y, …)` and `Date.UTC(y, …)` remap years 0-99 to 1900-1999. Every oracle below is built with
|
|
1491
|
+
* `setUTCFullYear` (never the remapping constructors), so it does not share the bug. Years >= 100 are
|
|
1492
|
+
* pinned too, so the fix cannot change them.
|
|
1493
|
+
*/
|
|
1494
|
+
describe('NileCalendar years 0-99 (Date remap regression)', () => {
|
|
1495
|
+
const oracleDow = (y, m0, d) => {
|
|
1496
|
+
const dt = new Date(0);
|
|
1497
|
+
dt.setUTCFullYear(y, m0, d);
|
|
1498
|
+
return dt.getUTCDay();
|
|
1499
|
+
};
|
|
1500
|
+
const oracleDim = (y, m0) => {
|
|
1501
|
+
const dt = new Date(0);
|
|
1502
|
+
dt.setUTCFullYear(y, m0 + 1, 0);
|
|
1503
|
+
return dt.getUTCDate();
|
|
1504
|
+
};
|
|
1505
|
+
const make = (variant, year, month0) => fixture(variant === 'dtp'
|
|
1506
|
+
? html `<nile-calendar variant="date-time-picker" timezone="UTC" .startYear=${0} .endYear=${2100}></nile-calendar>`
|
|
1507
|
+
: html `<nile-calendar .startYear=${0} .endYear=${2100}></nile-calendar>`).then(async (el) => {
|
|
1508
|
+
el.currentYear = year;
|
|
1509
|
+
el.currentMonth = month0;
|
|
1510
|
+
if (variant === 'dtp')
|
|
1511
|
+
el.expanded = true;
|
|
1512
|
+
await el.updateComplete;
|
|
1513
|
+
return el;
|
|
1514
|
+
});
|
|
1515
|
+
const cells = (el) => Array.from(el.shadowRoot.querySelectorAll('.day_date'));
|
|
1516
|
+
const realCells = (el) => cells(el).filter((c) => !c.classList.contains('filler'));
|
|
1517
|
+
// (year, month0): a weekday/leap sample inside 0-99, plus 100/1900/2000 unchanged-behaviour anchors
|
|
1518
|
+
const SAMPLES = [[1, 0], [50, 5], [99, 11], [0, 1], [4, 1], [100, 0], [1900, 1], [2000, 1]];
|
|
1519
|
+
for (const variant of ['default', 'dtp']) {
|
|
1520
|
+
it(`Z1-${variant}. the grid lays out the real weekdays and month length of the displayed year`, async () => {
|
|
1521
|
+
for (const [y, m] of SAMPLES) {
|
|
1522
|
+
const el = await make(variant, y, m);
|
|
1523
|
+
const all = cells(el);
|
|
1524
|
+
const first = all.findIndex((c) => !c.classList.contains('filler'));
|
|
1525
|
+
// published default grid: `new Date(y, m, ...)`, which remaps years 0-99 to 19xx; only the variant shows the real year
|
|
1526
|
+
const dow = variant === 'default' ? new Date(y, m, 1).getDay() : oracleDow(y, m, 1);
|
|
1527
|
+
const dim = variant === 'default' ? new Date(y, m + 1, 0).getDate() : oracleDim(y, m);
|
|
1528
|
+
expect(first, `year ${y} month ${m + 1}: leading blanks = weekday of the 1st`).to.equal(dow);
|
|
1529
|
+
expect(realCells(el).length, `year ${y} month ${m + 1}: days in month`).to.equal(dim);
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1532
|
+
it(`Z2-${variant}. a day cell's accessible name carries the true year and weekday, not 19xx`, async () => {
|
|
1533
|
+
const el = await make(variant, 50, 5);
|
|
1534
|
+
if (variant === 'default') {
|
|
1535
|
+
// published markup: the default grid has no accessible day names at all
|
|
1536
|
+
expect(realCells(el)[14].hasAttribute('aria-label'), 'no aria-label on a default day cell').to.be.false;
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
const label = realCells(el)[14].getAttribute('aria-label') ?? '';
|
|
1540
|
+
const wd = new Intl.DateTimeFormat('en-US', { weekday: 'long', timeZone: 'UTC' }).format((() => { const d = new Date(0); d.setUTCFullYear(50, 5, 15); return d; })());
|
|
1541
|
+
expect(label, `aria-label "${label}"`).to.contain(wd);
|
|
1542
|
+
expect(label, `aria-label "${label}"`).to.not.match(/19\d\d/);
|
|
1543
|
+
});
|
|
1544
|
+
it(`Z3-${variant}. clicking a day in year 50 selects year 50`, async () => {
|
|
1545
|
+
const el = await make(variant, 50, 5);
|
|
1546
|
+
const seen = [];
|
|
1547
|
+
el.addEventListener('nile-change', ((e) => { if (e.detail?.value instanceof Date)
|
|
1548
|
+
seen.push(e.detail.value); }));
|
|
1549
|
+
realCells(el)[14].click();
|
|
1550
|
+
await el.updateComplete;
|
|
1551
|
+
expect(el.value, 'selected value').to.be.instanceOf(Date);
|
|
1552
|
+
if (variant === 'default') {
|
|
1553
|
+
// published: selectDate builds `new Date(year, month, day)`, which remaps year 50 to 1950
|
|
1554
|
+
const exp = new Date(50, 5, 15);
|
|
1555
|
+
expect(el.value.getTime(), 'selected value').to.equal(exp.getTime());
|
|
1556
|
+
expect(seen[0]?.getTime(), 'emitted value').to.equal(exp.getTime());
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
expect(el.value.getFullYear(), 'year').to.equal(50);
|
|
1560
|
+
expect(el.value.getMonth(), 'month').to.equal(5);
|
|
1561
|
+
expect(el.value.getDate(), 'day').to.equal(15);
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
it('Z4. the selected day is highlighted in the year-50 grid (variant); the default grid keeps its published selection', async () => {
|
|
1565
|
+
for (const variant of ['default', 'dtp']) {
|
|
1566
|
+
const el = await make(variant, 50, 5);
|
|
1567
|
+
realCells(el)[14].click();
|
|
1568
|
+
await el.updateComplete;
|
|
1569
|
+
if (variant === 'default') {
|
|
1570
|
+
expect(el.value.getTime(), 'published selection (year 50 remapped to 1950)').to.equal(new Date(50, 5, 15).getTime());
|
|
1571
|
+
continue;
|
|
1572
|
+
}
|
|
1573
|
+
const sel = cells(el).filter((c) => c.classList.contains('selected-date'));
|
|
1574
|
+
expect(sel.length, `${variant}: one highlighted day`).to.equal(1);
|
|
1575
|
+
expect(sel[0].textContent.trim(), `${variant}: it is the 15th`).to.equal('15');
|
|
1576
|
+
}
|
|
1577
|
+
});
|
|
1578
|
+
describe('allowedDates / startYear-endYear validation', () => {
|
|
1579
|
+
const allowed = async (startDate, endDate, variant = 'default') => fixture(html `<nile-calendar variant=${variant} allowedDates=${JSON.stringify({ startDate, endDate })}></nile-calendar>`);
|
|
1580
|
+
for (const variant of ['default', 'date-time-picker']) {
|
|
1581
|
+
// published (default): Date.UTC remaps years 0-99 to 19xx, so membership is whatever the published formula says;
|
|
1582
|
+
// the variant keeps the literal year
|
|
1583
|
+
const verdict = (el, start, end, day, month, year, literal) => variant === 'default' ? published.allowed(start, end, day, month, year) : literal;
|
|
1584
|
+
it(`Z5 [${variant}]. a range that spans year 1..100 and the year-50 / year-100 / year-101 checks`, async () => {
|
|
1585
|
+
const el = await allowed('0001-01-01', '0100-12-31', variant);
|
|
1586
|
+
if (variant !== 'default')
|
|
1587
|
+
expect(el.allowedDatesLocal, 'range kept (start must not look later than end)').to.not.be.null;
|
|
1588
|
+
expect(el.isAllowedDate(15, 5, 50)).to.equal(verdict(el, '0001-01-01', '0100-12-31', 15, 5, 50, true));
|
|
1589
|
+
expect(el.isAllowedDate(15, 5, 100)).to.equal(verdict(el, '0001-01-01', '0100-12-31', 15, 5, 100, true));
|
|
1590
|
+
expect(el.isAllowedDate(1, 0, 101)).to.equal(verdict(el, '0001-01-01', '0100-12-31', 1, 0, 101, false));
|
|
1591
|
+
});
|
|
1592
|
+
it(`Z6 [${variant}]. a range inside year 1950 versus a range inside year 50`, async () => {
|
|
1593
|
+
const a = await allowed('1950-01-01', '1950-12-31', variant);
|
|
1594
|
+
expect(a.isAllowedDate(15, 5, 1950)).to.equal(verdict(a, '1950-01-01', '1950-12-31', 15, 5, 1950, true));
|
|
1595
|
+
expect(a.isAllowedDate(15, 5, 50), 'year 50 against a 1950-only range').to.equal(verdict(a, '1950-01-01', '1950-12-31', 15, 5, 50, false));
|
|
1596
|
+
const b = await allowed('0050-01-01', '0050-12-31', variant);
|
|
1597
|
+
expect(b.isAllowedDate(15, 5, 50)).to.equal(verdict(b, '0050-01-01', '0050-12-31', 15, 5, 50, true));
|
|
1598
|
+
expect(b.isAllowedDate(15, 5, 1950), 'year 1950 against a year-50-only range').to.equal(verdict(b, '0050-01-01', '0050-12-31', 15, 5, 1950, false));
|
|
1599
|
+
});
|
|
1600
|
+
it(`Z7 [${variant}]. a year-1 start / year-99 end, and year 100`, async () => {
|
|
1601
|
+
const el = await allowed('0001-06-10', '0099-06-20', variant);
|
|
1602
|
+
for (const [d, m, y, literal] of [[9, 5, 1, false], [10, 5, 1, true], [20, 5, 99, true], [21, 5, 99, false], [15, 5, 100, false]]) {
|
|
1603
|
+
expect(el.isAllowedDate(d, m, y), `${d}/${m + 1}/${y}`).to.equal(verdict(el, '0001-06-10', '0099-06-20', d, m, y, literal));
|
|
1604
|
+
}
|
|
1605
|
+
});
|
|
1606
|
+
it(`Z8 [${variant}]. years >= 100 keep their behaviour (inclusive bounds)`, async () => {
|
|
1607
|
+
const el = await allowed('2026-06-10', '2026-06-20', variant);
|
|
1608
|
+
expect(el.isAllowedDate(9, 5, 2026)).to.be.false;
|
|
1609
|
+
expect(el.isAllowedDate(10, 5, 2026)).to.be.true;
|
|
1610
|
+
expect(el.isAllowedDate(20, 5, 2026)).to.be.true;
|
|
1611
|
+
expect(el.isAllowedDate(21, 5, 2026)).to.be.false;
|
|
1612
|
+
const old = await allowed('0100-01-01', '0100-12-31', variant);
|
|
1613
|
+
expect(old.isAllowedDate(15, 5, 100)).to.be.true;
|
|
1614
|
+
expect(old.isAllowedDate(15, 5, 101)).to.be.false;
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
it('Z9. an allowedDates range that starts in year 1 blocks a year-50 grid cell correctly', async () => {
|
|
1618
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" timezone="UTC" .startYear=${0}
|
|
1619
|
+
allowedDates=${JSON.stringify({ startDate: '0050-06-10', endDate: '0050-06-20' })}></nile-calendar>`);
|
|
1620
|
+
el.currentYear = 50;
|
|
1621
|
+
el.currentMonth = 5;
|
|
1622
|
+
el.expanded = true;
|
|
1623
|
+
await el.updateComplete;
|
|
1624
|
+
const open = Array.from(el.shadowRoot.querySelectorAll('.day_date:not(.filler):not(.not-allowed)'))
|
|
1625
|
+
.map((c) => c.textContent.trim());
|
|
1626
|
+
expect(open[0]).to.equal('10');
|
|
1627
|
+
expect(open[open.length - 1]).to.equal('20');
|
|
1628
|
+
expect(open.length).to.equal(11);
|
|
1629
|
+
});
|
|
1630
|
+
});
|
|
1631
|
+
for (const variant of ['default', 'date-time-picker']) {
|
|
1632
|
+
it(`Z10 [${variant}]. typed range input (YYYY): the variant keeps the literal year, the default keeps the published remap`, async () => {
|
|
1633
|
+
const el = await fixture(html `<nile-calendar variant=${variant} range dateFormat="YYYY-MM-DD"></nile-calendar>`);
|
|
1634
|
+
for (const y of [1, 50, 99, 100, 2026]) {
|
|
1635
|
+
const text = `${String(y).padStart(4, '0')}-06-15`;
|
|
1636
|
+
const d = el.parseDate(text);
|
|
1637
|
+
const expectedYear = variant === 'default' ? published.parse(text, 'YYYY-MM-DD').getFullYear() : y;
|
|
1638
|
+
expect(d.getFullYear(), `year ${y}`).to.equal(expectedYear);
|
|
1639
|
+
expect(d.getMonth()).to.equal(5);
|
|
1640
|
+
expect(d.getDate()).to.equal(15);
|
|
1641
|
+
}
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
it('Z11. Feb 29 of year 0 (a leap year) is not clamped by a zoned value', async () => {
|
|
1645
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker"
|
|
1646
|
+
value="0000-02-29T10:00:00.000[GMT+00:00 UTC]"></nile-calendar>`);
|
|
1647
|
+
await el.updateComplete;
|
|
1648
|
+
expect(el.value.getFullYear(), 'year').to.equal(0);
|
|
1649
|
+
expect(el.value.getMonth(), 'month').to.equal(1);
|
|
1650
|
+
expect(el.value.getDate(), 'Feb 29 exists in year 0').to.equal(29);
|
|
1651
|
+
const non = await fixture(html `<nile-calendar variant="date-time-picker"
|
|
1652
|
+
value="0001-02-29T10:00:00.000[GMT+00:00 UTC]"></nile-calendar>`);
|
|
1653
|
+
expect(non.value.getDate(), 'year 1 is not leap: clamps to 28').to.equal(28);
|
|
1654
|
+
});
|
|
1655
|
+
});
|
|
1656
|
+
describe('NileCalendar YYYY formatting (years 0-99 padded to four digits)', () => {
|
|
1657
|
+
const mk = (fmt, variant = 'default') => fixture(html `<nile-calendar variant=${variant} .dateFormat=${fmt} .startYear=${0} .endYear=${2100}></nile-calendar>`);
|
|
1658
|
+
const fmt = (el, y, m0, d) => el.formatDate(localDateFromFields(y, m0, d));
|
|
1659
|
+
const parse = (el, s) => el.parseDate(s, null);
|
|
1660
|
+
it('F1. formatYear4 pads only years 0-99 and leaves every other year as String(year)', () => {
|
|
1661
|
+
expect([0, 1, 50, 99].map(formatYear4)).to.deep.equal(['0000', '0001', '0050', '0099']);
|
|
1662
|
+
for (const y of [100, 999, 1000, 1950, 2000, 9999, 10000])
|
|
1663
|
+
expect(formatYear4(y), `year ${y}`).to.equal(String(y));
|
|
1664
|
+
expect(formatYear4(-5)).to.equal('-5');
|
|
1665
|
+
});
|
|
1666
|
+
for (const variant of ['default', 'date-time-picker']) {
|
|
1667
|
+
it(`F2 [${variant}]. YYYY for years 0-99: the variant pads to four digits, the default prints the published year`, async () => {
|
|
1668
|
+
for (const [f, padded] of [
|
|
1669
|
+
['MM/DD/YYYY', '06/15/0050'], ['DD/MM/YYYY', '15/06/0050'], ['YYYY-MM-DD', '0050-06-15'],
|
|
1670
|
+
]) {
|
|
1671
|
+
const el = await mk(f, variant);
|
|
1672
|
+
const exp = variant === 'default' ? published.format(localDateFromFields(50, 5, 15), f) : padded;
|
|
1673
|
+
expect(fmt(el, 50, 5, 15), f).to.equal(exp);
|
|
1674
|
+
}
|
|
1675
|
+
const el = await mk('MM/DD/YYYY', variant);
|
|
1676
|
+
for (const [y, m0, d] of [[0, 0, 1], [1, 0, 1], [99, 11, 31]]) {
|
|
1677
|
+
const exp = variant === 'default'
|
|
1678
|
+
? published.format(localDateFromFields(y, m0, d), 'MM/DD/YYYY')
|
|
1679
|
+
: `${String(m0 + 1).padStart(2, '0')}/${String(d).padStart(2, '0')}/${String(y).padStart(4, '0')}`;
|
|
1680
|
+
expect(fmt(el, y, m0, d), `year ${y}`).to.equal(exp);
|
|
1681
|
+
}
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
it('F3. years >= 100 are byte-identical to before (100 and 999 pinned)', async () => {
|
|
1685
|
+
const el = await mk('MM/DD/YYYY');
|
|
1686
|
+
for (const y of [100, 999, 1000, 1950, 2000, 9999]) {
|
|
1687
|
+
expect(fmt(el, y, 5, 15), `year ${y}`).to.equal(`06/15/${y}`);
|
|
1688
|
+
}
|
|
1689
|
+
});
|
|
1690
|
+
it('F4. YY is unchanged (last two digits of the year)', async () => {
|
|
1691
|
+
const el = await mk('DD/MM/YY');
|
|
1692
|
+
expect(fmt(el, 1, 0, 1), 'pre-existing: YY is slice(-2) of the bare year, so year 1 is "1"').to.equal('01/01/1');
|
|
1693
|
+
expect(fmt(el, 50, 5, 15)).to.equal('15/06/50');
|
|
1694
|
+
expect(fmt(el, 2026, 5, 15)).to.equal('15/06/26');
|
|
1695
|
+
});
|
|
1696
|
+
for (const variant of ['default', 'date-time-picker']) {
|
|
1697
|
+
it(`F5 [${variant}]. format -> parseDate: the variant round-trips years 0, 1, 50, 99, 2000; the default matches the published pair`, async () => {
|
|
1698
|
+
for (const f of ['MM/DD/YYYY', 'DD/MM/YYYY', 'YYYY-MM-DD']) {
|
|
1699
|
+
const el = await mk(f, variant);
|
|
1700
|
+
for (const y of [0, 1, 50, 99, 2000]) {
|
|
1701
|
+
const text = fmt(el, y, 5, 15);
|
|
1702
|
+
const d = parse(el, text);
|
|
1703
|
+
if (variant === 'default') {
|
|
1704
|
+
const exp = published.parse(published.format(localDateFromFields(y, 5, 15), f), f);
|
|
1705
|
+
expect(text, `${f} year ${y} text`).to.equal(published.format(localDateFromFields(y, 5, 15), f));
|
|
1706
|
+
expect(d?.getTime() ?? null, `${f} year ${y}`).to.equal(exp?.getTime() ?? null);
|
|
1707
|
+
continue;
|
|
1708
|
+
}
|
|
1709
|
+
expect(d, `${f} year ${y}`).to.not.be.null;
|
|
1710
|
+
expect([d.getFullYear(), d.getMonth(), d.getDate()], `${f} year ${y}`).to.deep.equal([y, 5, 15]);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
});
|
|
1714
|
+
it(`F6 [${variant}]. Feb 29 of year 0: the variant formats it as 0000 and round-trips it; the default matches the published pair`, async () => {
|
|
1715
|
+
const el = await mk('MM/DD/YYYY', variant);
|
|
1716
|
+
if (variant === 'default') {
|
|
1717
|
+
const text = fmt(el, 0, 1, 29);
|
|
1718
|
+
expect(text).to.equal(published.format(localDateFromFields(0, 1, 29), 'MM/DD/YYYY'));
|
|
1719
|
+
expect(parse(el, text)?.getTime() ?? null).to.equal(published.parse(text, 'MM/DD/YYYY')?.getTime() ?? null);
|
|
1720
|
+
expect(fmt(el, 100, 1, 29), 'year 100 Feb 29 overflows to Mar 1').to.equal('03/01/100');
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
expect(fmt(el, 0, 1, 29)).to.equal('02/29/0000');
|
|
1724
|
+
const d = parse(el, '02/29/0000');
|
|
1725
|
+
expect([d.getFullYear(), d.getMonth(), d.getDate()]).to.deep.equal([0, 1, 29]);
|
|
1726
|
+
expect(fmt(el, 100, 1, 29), 'year 100 Feb 29 overflows to Mar 1').to.equal('03/01/100');
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
it('F7. the default range trigger inputs display a year-50 selection exactly as published', async () => {
|
|
1730
|
+
const el = await fixture(html `<nile-calendar range .dateFormat=${'MM/DD/YYYY'} .startYear=${0} .endYear=${2100}></nile-calendar>`);
|
|
1731
|
+
el.startDate = localDateFromFields(50, 5, 15);
|
|
1732
|
+
el.endDate = localDateFromFields(50, 5, 20);
|
|
1733
|
+
await el.updateComplete;
|
|
1734
|
+
const vals = Array.from(el.shadowRoot.querySelectorAll('.date-input')).map((i) => i.value);
|
|
1735
|
+
expect(vals).to.deep.equal([
|
|
1736
|
+
published.format(localDateFromFields(50, 5, 15), 'MM/DD/YYYY'),
|
|
1737
|
+
published.format(localDateFromFields(50, 5, 20), 'MM/DD/YYYY'),
|
|
1738
|
+
]);
|
|
1739
|
+
});
|
|
1740
|
+
it('F8. selecting a day in year 50 in the default calendar selects and displays what the published calendar does', async () => {
|
|
1741
|
+
const el = await fixture(html `<nile-calendar range .dateFormat=${'MM/DD/YYYY'} .startYear=${0} .endYear=${2100}></nile-calendar>`);
|
|
1742
|
+
el.currentYear = 50;
|
|
1743
|
+
el.currentMonth = 5;
|
|
1744
|
+
await el.updateComplete;
|
|
1745
|
+
el.shadowRoot.querySelectorAll('.day_date:not(.filler)')[14].click();
|
|
1746
|
+
await el.updateComplete;
|
|
1747
|
+
const exp = new Date(50, 5, 15); // published: year 50 is remapped to 1950
|
|
1748
|
+
expect(el.startDate.getTime()).to.equal(exp.getTime());
|
|
1749
|
+
expect(el.shadowRoot.querySelector('.date-input').value).to.equal(published.format(exp, 'MM/DD/YYYY'));
|
|
1750
|
+
});
|
|
1751
|
+
});
|
|
1752
|
+
describe('NileCalendar default variant: value seeding is exactly the published conversion', () => {
|
|
1753
|
+
const ymd = (d) => [d.getFullYear(), d.getMonth() + 1, d.getDate()];
|
|
1754
|
+
const seed = async (value, extra = '') => {
|
|
1755
|
+
const el = (extra === 'variant-default'
|
|
1756
|
+
? await fixture(html `<nile-calendar variant="default" value=${value}></nile-calendar>`)
|
|
1757
|
+
: await fixture(html `<nile-calendar value=${value}></nile-calendar>`));
|
|
1758
|
+
await el.updateComplete;
|
|
1759
|
+
return el;
|
|
1760
|
+
};
|
|
1761
|
+
// every kind of string a consumer can pass: the default calendar must keep the published (host-offset shifted) result
|
|
1762
|
+
const RAWS = [
|
|
1763
|
+
'2026-06-15', '2026-01-01', '2026-12-31', '2025-12-31', '2024-02-29', '2026-03-08', '2026-11-01', // date-only
|
|
1764
|
+
'2026-02-31', '2026-02-30', '2026-04-31', '2026-02-99', // overflowing day
|
|
1765
|
+
'0050-06-15', '0001-01-01', '0099-12-31', // years 0-99
|
|
1766
|
+
'2026-06-15T10:00:00Z', '2026-01-01T00:00:00.000Z', '2026-12-31T23:59:59Z', // instants
|
|
1767
|
+
'2026-06-15T23:30:00+05:30', '2026-06-15T20:00:00-04:00', // explicit offsets
|
|
1768
|
+
'2026-06-15T10:00:00', '2026-06-15T12:00:00', '2025-12-31T23:30:00', '2026-01-01T00:30:00', // offset-less
|
|
1769
|
+
];
|
|
1770
|
+
it('D1. the value attribute is seeded exactly as published, for every kind of string', async () => {
|
|
1771
|
+
for (const raw of RAWS) {
|
|
1772
|
+
const el = await seed(raw);
|
|
1773
|
+
expectPublishedSeed(el, raw, `initial ${raw}`);
|
|
1774
|
+
const exp = published.seed(raw);
|
|
1775
|
+
if (exp)
|
|
1776
|
+
expect([el.currentYear, el.currentMonth], `${raw} grid`).to.deep.equal([exp.getFullYear(), exp.getMonth()]);
|
|
1777
|
+
}
|
|
1778
|
+
});
|
|
1779
|
+
it('D2. the same when the value is set after first render (attribute and property)', async () => {
|
|
1780
|
+
const el = await seed('2026-06-15T12:00:00');
|
|
1781
|
+
for (const raw of RAWS) {
|
|
1782
|
+
el.setAttribute('value', raw);
|
|
1783
|
+
await el.updateComplete;
|
|
1784
|
+
const exp = published.seed(raw);
|
|
1785
|
+
if (exp)
|
|
1786
|
+
expect(el.value.getTime(), `attribute ${raw}`).to.equal(exp.getTime());
|
|
1787
|
+
}
|
|
1788
|
+
el.valueAttribute = '2027-01-01T10:00:00Z';
|
|
1789
|
+
await el.updateComplete;
|
|
1790
|
+
expect(el.value.getTime(), 'property').to.equal(published.seed('2027-01-01T10:00:00Z').getTime());
|
|
1791
|
+
});
|
|
1792
|
+
it('D3. variant="default" seeds exactly like no variant attribute', async () => {
|
|
1793
|
+
for (const raw of RAWS) {
|
|
1794
|
+
const plain = await seed(raw);
|
|
1795
|
+
const explicit = await seed(raw, 'variant-default');
|
|
1796
|
+
expect(explicit.value?.getTime() ?? null, raw).to.equal(plain.value?.getTime() ?? null);
|
|
1797
|
+
}
|
|
1798
|
+
});
|
|
1799
|
+
it('D4. a day clicked after seeding is the clicked day, and the emitted payload is a plain { value: Date }', async () => {
|
|
1800
|
+
const el = await seed('2026-06-15T12:00:00');
|
|
1801
|
+
expect(ymd(el.value)).to.deep.equal([2026, 6, 15]);
|
|
1802
|
+
const seen = [];
|
|
1803
|
+
for (const n of ['nile-change', 'nile-changed'])
|
|
1804
|
+
el.addEventListener(n, ((e) => seen.push([n, e.detail])));
|
|
1805
|
+
el.shadowRoot.querySelectorAll('.day_date:not(.filler)')[19].click();
|
|
1806
|
+
await el.updateComplete;
|
|
1807
|
+
expect(ymd(el.value), 'selected').to.deep.equal([2026, 6, 20]);
|
|
1808
|
+
expect(seen.map(x => x[0]), 'events, in the published order').to.deep.equal(['nile-changed', 'nile-change']);
|
|
1809
|
+
for (const [, detail] of seen) {
|
|
1810
|
+
expect(Object.keys(detail), 'payload keys').to.deep.equal(['value']);
|
|
1811
|
+
expect(ymd(detail.value), 'payload day').to.deep.equal([2026, 6, 20]);
|
|
1812
|
+
}
|
|
1813
|
+
});
|
|
1814
|
+
it('D8. an invalid value string still leaves the default calendar unselected', async () => {
|
|
1815
|
+
for (const raw of ['not-a-date', '2026-13-01', '2026-02-00']) {
|
|
1816
|
+
const el = await seed(raw);
|
|
1817
|
+
expect(el.value == null, raw).to.be.true;
|
|
1818
|
+
}
|
|
1819
|
+
});
|
|
1820
|
+
it('D9. the date-time-picker variant is untouched: date-only still clamps and keeps the day', async () => {
|
|
1821
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" value="2026-02-31"></nile-calendar>`);
|
|
1822
|
+
await el.updateComplete;
|
|
1823
|
+
expect(ymd(el.value), 'clamped to Feb 28').to.deep.equal([2026, 2, 28]);
|
|
1824
|
+
const el2 = await fixture(html `<nile-calendar variant="date-time-picker" value="2026-06-15"></nile-calendar>`);
|
|
1825
|
+
await el2.updateComplete;
|
|
1826
|
+
expect(ymd(el2.value)).to.deep.equal([2026, 6, 15]);
|
|
1827
|
+
});
|
|
1828
|
+
});
|
|
1829
|
+
describe('NileCalendar range: date-only YYYY-MM-DD values keep their calendar day in every host timezone', () => {
|
|
1830
|
+
const ymd = (d) => [d.getFullYear(), d.getMonth() + 1, d.getDate()];
|
|
1831
|
+
const hms = (d) => [d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()];
|
|
1832
|
+
const json = (s, e) => JSON.stringify({ startDate: s, endDate: e });
|
|
1833
|
+
const seed = async (value, variant) => {
|
|
1834
|
+
const el = await fixture(html `<nile-calendar range variant=${variant} value=${value}></nile-calendar>`);
|
|
1835
|
+
await el.updateComplete;
|
|
1836
|
+
return el;
|
|
1837
|
+
};
|
|
1838
|
+
const RANGES = [
|
|
1839
|
+
['2026-06-10', '2026-06-20', [2026, 6, 10], [2026, 6, 20]],
|
|
1840
|
+
['2025-12-31', '2026-01-02', [2025, 12, 31], [2026, 1, 2]],
|
|
1841
|
+
['2024-02-28', '2024-03-01', [2024, 2, 28], [2024, 3, 1]],
|
|
1842
|
+
['2024-02-29', '2024-02-29', [2024, 2, 29], [2024, 2, 29]],
|
|
1843
|
+
['2026-03-07', '2026-03-09', [2026, 3, 7], [2026, 3, 9]],
|
|
1844
|
+
['2026-10-31', '2026-11-02', [2026, 10, 31], [2026, 11, 2]],
|
|
1845
|
+
['2026-03-28', '2026-03-30', [2026, 3, 28], [2026, 3, 30]],
|
|
1846
|
+
];
|
|
1847
|
+
for (const variant of ['default', 'date-time-picker']) {
|
|
1848
|
+
it(`R1 [${variant}]. initial date-only range lands on the written days: start 00:00:00, end 23:59:59.999`, async () => {
|
|
1849
|
+
for (const [s, e, es, ee] of RANGES) {
|
|
1850
|
+
const el = await seed(json(s, e), variant);
|
|
1851
|
+
if (variant === 'default') {
|
|
1852
|
+
// published: each bound is `new Date(raw)` (a date-only string is UTC midnight) and the end is not end-of-day
|
|
1853
|
+
expect(el.startDate.getTime(), `${s} start`).to.equal(published.bound(s).getTime());
|
|
1854
|
+
expect(el.endDate.getTime(), `${e} end`).to.equal(published.bound(e).getTime());
|
|
1855
|
+
continue;
|
|
1856
|
+
}
|
|
1857
|
+
expect(ymd(el.startDate), `${s} start`).to.deep.equal(es);
|
|
1858
|
+
expect(ymd(el.endDate), `${e} end`).to.deep.equal(ee);
|
|
1859
|
+
expect(hms(el.startDate), `${s} start time`).to.deep.equal([0, 0, 0, 0]);
|
|
1860
|
+
expect(hms(el.endDate), `${e} end time`).to.deep.equal([23, 59, 59, 999]);
|
|
1861
|
+
}
|
|
1862
|
+
});
|
|
1863
|
+
it(`R2 [${variant}]. a date-only range set after first render (attribute and property) lands on the written days`, async () => {
|
|
1864
|
+
const el = await seed(json('2026-06-10', '2026-06-20'), variant);
|
|
1865
|
+
for (const [s, e, es, ee] of RANGES) {
|
|
1866
|
+
el.setAttribute('value', json(s, e));
|
|
1867
|
+
await el.updateComplete;
|
|
1868
|
+
if (variant === 'default') {
|
|
1869
|
+
expect(el.startDate.getTime(), `attr ${s}`).to.equal(published.bound(s).getTime());
|
|
1870
|
+
expect(el.endDate.getTime(), `attr ${e}`).to.equal(published.bound(e).getTime());
|
|
1871
|
+
continue;
|
|
1872
|
+
}
|
|
1873
|
+
expect(ymd(el.startDate), `attr ${s}`).to.deep.equal(es);
|
|
1874
|
+
expect(ymd(el.endDate), `attr ${e}`).to.deep.equal(ee);
|
|
1875
|
+
}
|
|
1876
|
+
el.valueAttribute = json('2027-01-01', '2027-01-31');
|
|
1877
|
+
await el.updateComplete;
|
|
1878
|
+
if (variant === 'default') {
|
|
1879
|
+
expect(el.startDate.getTime(), 'property start').to.equal(published.bound('2027-01-01').getTime());
|
|
1880
|
+
expect(el.endDate.getTime(), 'property end').to.equal(published.bound('2027-01-31').getTime());
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
expect(ymd(el.startDate)).to.deep.equal([2027, 1, 1]);
|
|
1884
|
+
expect(ymd(el.endDate)).to.deep.equal([2027, 1, 31]);
|
|
1885
|
+
});
|
|
1886
|
+
it(`R3 [${variant}]. a range emitted by a day click and re-seeded as date-only shows the same days`, async () => {
|
|
1887
|
+
const el = await seed('', variant);
|
|
1888
|
+
el.selectDate(10, 5, 2026);
|
|
1889
|
+
el.selectDate(20, 5, 2026);
|
|
1890
|
+
const s = ymd(el.startDate);
|
|
1891
|
+
const e = ymd(el.endDate);
|
|
1892
|
+
const again = await seed(json('2026-06-10', '2026-06-20'), variant);
|
|
1893
|
+
if (variant === 'default') {
|
|
1894
|
+
// published: a date-only string re-seeds as `new Date(raw)` (UTC midnight), not as the clicked local days
|
|
1895
|
+
expect(again.startDate.getTime()).to.equal(published.bound('2026-06-10').getTime());
|
|
1896
|
+
expect(again.endDate.getTime()).to.equal(published.bound('2026-06-20').getTime());
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
expect(ymd(again.startDate)).to.deep.equal(s);
|
|
1900
|
+
expect(ymd(again.endDate)).to.deep.equal(e);
|
|
1901
|
+
expect(hms(again.endDate)).to.deep.equal(hms(el.endDate));
|
|
1902
|
+
});
|
|
1903
|
+
it(`R4 [${variant}]. years 0-99 in a date-only range keep their literal year (no 19xx remap)`, async () => {
|
|
1904
|
+
const el = await seed(json('0001-01-05', '0099-12-31'), variant);
|
|
1905
|
+
if (variant === 'default') {
|
|
1906
|
+
expect(el.startDate.getTime()).to.equal(published.bound('0001-01-05').getTime());
|
|
1907
|
+
expect(el.endDate.getTime()).to.equal(published.bound('0099-12-31').getTime());
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
expect(ymd(el.startDate)).to.deep.equal([1, 1, 5]);
|
|
1911
|
+
expect(ymd(el.endDate)).to.deep.equal([99, 12, 31]);
|
|
1912
|
+
const el2 = await seed(json('0050-06-10', '0050-06-20'), variant);
|
|
1913
|
+
expect(ymd(el2.startDate)).to.deep.equal([50, 6, 10]);
|
|
1914
|
+
expect(ymd(el2.endDate)).to.deep.equal([50, 6, 20]);
|
|
1915
|
+
});
|
|
1916
|
+
it(`R5 [${variant}]. non-date-only range strings are unchanged (offset-less, Z, explicit offset)`, async () => {
|
|
1917
|
+
for (const [s, e] of [
|
|
1918
|
+
['2026-06-10T08:30:00', '2026-06-20T17:45:10'],
|
|
1919
|
+
['2026-06-10T08:30:00Z', '2026-06-20T17:45:10Z'],
|
|
1920
|
+
['2026-06-10T08:30:00+05:30', '2026-06-20T17:45:10-04:00'],
|
|
1921
|
+
]) {
|
|
1922
|
+
const el = await seed(json(s, e), variant);
|
|
1923
|
+
expect(el.startDate.getTime(), s).to.equal(new Date(s).getTime());
|
|
1924
|
+
expect(el.endDate.getTime(), e).to.equal(new Date(e).getTime());
|
|
1925
|
+
}
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
it('R6. an unparsable date-only range leaves the range empty instead of throwing', async () => {
|
|
1929
|
+
const el = await seed(json('2026-13-45', '2026-99-99'), 'default');
|
|
1930
|
+
expect(el.startDate === null || isNaN(el.startDate.getTime())).to.equal(true);
|
|
1931
|
+
});
|
|
1932
|
+
});
|
|
1933
|
+
describe('NileCalendar range seed: visible month', () => {
|
|
1934
|
+
const json = (s, e) => JSON.stringify({ startDate: s, endDate: e });
|
|
1935
|
+
const seed = async (variant, value) => {
|
|
1936
|
+
const el = await fixture(html `<nile-calendar range variant=${variant} value=${value}></nile-calendar>`);
|
|
1937
|
+
await el.updateComplete;
|
|
1938
|
+
return el;
|
|
1939
|
+
};
|
|
1940
|
+
const now = () => [new Date().getFullYear(), new Date().getMonth()];
|
|
1941
|
+
const SPANS = [
|
|
1942
|
+
['2026-06-10', '2026-06-20', [2026, 5]],
|
|
1943
|
+
['2026-06-28', '2026-07-03', [2026, 5]],
|
|
1944
|
+
['2025-12-20', '2026-01-05', [2025, 11]],
|
|
1945
|
+
['2024-02-29', '2024-03-01', [2024, 1]],
|
|
1946
|
+
['2026-03-07', '2026-03-09', [2026, 2]],
|
|
1947
|
+
['2026-01-31', '2027-02-01', [2026, 0]],
|
|
1948
|
+
['0050-06-10', '0050-06-20', [50, 5]],
|
|
1949
|
+
];
|
|
1950
|
+
describe('date-time-picker variant: a seeded range shows the start month', () => {
|
|
1951
|
+
it('B3-1. initial range seed navigates to the start month and year (spans across months and years)', async () => {
|
|
1952
|
+
for (const [s, e, expected] of SPANS) {
|
|
1953
|
+
const el = await seed('date-time-picker', json(s, e));
|
|
1954
|
+
expect([el.currentYear, el.currentMonth], s).to.deep.equal(expected);
|
|
1955
|
+
}
|
|
1956
|
+
});
|
|
1957
|
+
it('B3-2. a dynamically updated range (attribute and property) navigates to the new start month', async () => {
|
|
1958
|
+
const el = await seed('date-time-picker', json('2026-06-10', '2026-06-20'));
|
|
1959
|
+
for (const [s, e, expected] of SPANS) {
|
|
1960
|
+
el.setAttribute('value', json(s, e));
|
|
1961
|
+
await el.updateComplete;
|
|
1962
|
+
expect([el.currentYear, el.currentMonth], `attr ${s}`).to.deep.equal(expected);
|
|
1963
|
+
}
|
|
1964
|
+
el.valueAttribute = json('2027-09-01', '2027-10-05');
|
|
1965
|
+
await el.updateComplete;
|
|
1966
|
+
expect([el.currentYear, el.currentMonth]).to.deep.equal([2027, 8]);
|
|
1967
|
+
});
|
|
1968
|
+
it('B3-3. non-date-only start strings navigate to the start instant\'s local month', async () => {
|
|
1969
|
+
const el = await seed('date-time-picker', json('2026-06-10T08:30:00', '2026-08-20T17:45:10'));
|
|
1970
|
+
expect([el.currentYear, el.currentMonth]).to.deep.equal([2026, 5]);
|
|
1971
|
+
});
|
|
1972
|
+
it('B3-4. start/end dates and times are unchanged by the navigation', async () => {
|
|
1973
|
+
const el = await seed('date-time-picker', json('2026-06-28', '2026-07-03'));
|
|
1974
|
+
expect(el.startDate.getTime()).to.equal(new Date(2026, 5, 28, 0, 0, 0, 0).getTime());
|
|
1975
|
+
expect(el.endDate.getTime()).to.equal(new Date(2026, 6, 3, 23, 59, 59, 999).getTime());
|
|
1976
|
+
});
|
|
1977
|
+
it('B3-5. an unparsable range does not move the month', async () => {
|
|
1978
|
+
const el = await seed('date-time-picker', json('nope', 'nope'));
|
|
1979
|
+
expect([el.currentYear, el.currentMonth]).to.deep.equal(now());
|
|
1980
|
+
});
|
|
1981
|
+
it('B3-6. a typed end date still navigates to the end month, and clicks/next-month still work', async () => {
|
|
1982
|
+
const el = await seed('date-time-picker', json('2026-06-10', '2026-08-20'));
|
|
1983
|
+
expect(el.currentMonth).to.equal(5);
|
|
1984
|
+
el.handleEndDateInput({ stopPropagation() { }, detail: { value: '08/20/2026' } });
|
|
1985
|
+
await el.updateComplete;
|
|
1986
|
+
expect([el.currentYear, el.currentMonth]).to.deep.equal([2026, 7]);
|
|
1987
|
+
el.currentMonth = 11;
|
|
1988
|
+
el.currentYear = 2026;
|
|
1989
|
+
el.nextMonth();
|
|
1990
|
+
expect([el.currentYear, el.currentMonth]).to.deep.equal([2027, 0]);
|
|
1991
|
+
});
|
|
1992
|
+
it('B3-7. a single (non-range) date-time-picker value navigation is unchanged', async () => {
|
|
1993
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" value="2025-12-31"></nile-calendar>`);
|
|
1994
|
+
await el.updateComplete;
|
|
1995
|
+
expect([el.currentYear, el.currentMonth]).to.deep.equal([2025, 11]);
|
|
1996
|
+
});
|
|
1997
|
+
});
|
|
1998
|
+
describe('default variant is unchanged: a seeded range does not navigate', () => {
|
|
1999
|
+
it('B3d-1. initial range seed keeps the current month and year', async () => {
|
|
2000
|
+
for (const [s, e] of SPANS) {
|
|
2001
|
+
const el = await seed('default', json(s, e));
|
|
2002
|
+
expect([el.currentYear, el.currentMonth], s).to.deep.equal(now());
|
|
2003
|
+
}
|
|
2004
|
+
});
|
|
2005
|
+
it('B3d-2. a dynamically updated range keeps the current month and year', async () => {
|
|
2006
|
+
const el = await seed('default', json('2026-06-10', '2026-06-20'));
|
|
2007
|
+
for (const [s, e] of SPANS) {
|
|
2008
|
+
el.setAttribute('value', json(s, e));
|
|
2009
|
+
await el.updateComplete;
|
|
2010
|
+
expect([el.currentYear, el.currentMonth], s).to.deep.equal(now());
|
|
2011
|
+
}
|
|
2012
|
+
});
|
|
2013
|
+
it('B3d-3. user navigation still moves the default calendar from where it is', async () => {
|
|
2014
|
+
const el = await seed('default', json('2026-06-10', '2026-06-20'));
|
|
2015
|
+
const [y, m] = now();
|
|
2016
|
+
el.currentYear = 2020;
|
|
2017
|
+
el.currentMonth = 11;
|
|
2018
|
+
el.nextMonth?.();
|
|
2019
|
+
await el.updateComplete;
|
|
2020
|
+
expect([el.currentYear, el.currentMonth]).to.deep.equal([2021, 0]);
|
|
2021
|
+
expect(y).to.be.a('number');
|
|
2022
|
+
expect(m).to.be.a('number');
|
|
2023
|
+
});
|
|
2024
|
+
});
|
|
2025
|
+
});
|
|
2026
|
+
describe('NileCalendar date-only overflow days clamp to the month end (same in every browser engine)', () => {
|
|
2027
|
+
const ymd = (d) => [d.getFullYear(), d.getMonth() + 1, d.getDate()];
|
|
2028
|
+
const hms = (d) => [d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()];
|
|
2029
|
+
const json = (s, e) => JSON.stringify({ startDate: s, endDate: e });
|
|
2030
|
+
const make = async (variant, attrs = {}) => {
|
|
2031
|
+
const el = document.createElement('nile-calendar');
|
|
2032
|
+
el.setAttribute('variant', variant);
|
|
2033
|
+
for (const [k, v] of Object.entries(attrs))
|
|
2034
|
+
el.setAttribute(k, v);
|
|
2035
|
+
document.body.appendChild(el);
|
|
2036
|
+
await el.updateComplete;
|
|
2037
|
+
return el;
|
|
2038
|
+
};
|
|
2039
|
+
const CASES = [
|
|
2040
|
+
['2026-02-31', [2026, 2, 28]], ['2026-02-30', [2026, 2, 28]], ['2026-02-29', [2026, 2, 28]],
|
|
2041
|
+
['2024-02-31', [2024, 2, 29]], ['2024-02-30', [2024, 2, 29]],
|
|
2042
|
+
['2026-04-31', [2026, 4, 30]], ['2026-06-31', [2026, 6, 30]], ['2026-09-31', [2026, 9, 30]], ['2026-11-31', [2026, 11, 30]],
|
|
2043
|
+
['2000-02-30', [2000, 2, 29]], ['1900-02-29', [1900, 2, 28]],
|
|
2044
|
+
['0050-02-31', [50, 2, 28]], ['0004-02-31', [4, 2, 29]],
|
|
2045
|
+
];
|
|
2046
|
+
const VALID = [
|
|
2047
|
+
['2026-02-28', [2026, 2, 28]], ['2024-02-29', [2024, 2, 29]], ['2026-04-30', [2026, 4, 30]], ['2026-01-31', [2026, 1, 31]],
|
|
2048
|
+
['2026-12-31', [2026, 12, 31]], ['2026-03-08', [2026, 3, 8]], ['2026-11-01', [2026, 11, 1]], ['0050-06-15', [50, 6, 15]],
|
|
2049
|
+
];
|
|
2050
|
+
let made = [];
|
|
2051
|
+
afterEach(() => { made.forEach(e => e.remove()); made = []; });
|
|
2052
|
+
const track = (e) => { made.push(e); return e; };
|
|
2053
|
+
for (const variant of ['default', 'date-time-picker']) {
|
|
2054
|
+
it(`C1 [${variant}]. single value: overflow days clamp (initial and dynamic attribute/property updates)`, async () => {
|
|
2055
|
+
if (variant === 'default') {
|
|
2056
|
+
// published: no clamp; an overflowing day is whatever `new Date(raw)` makes of it, shifted by the host offset
|
|
2057
|
+
for (const [raw] of CASES) {
|
|
2058
|
+
expectPublishedSeed(track(await make(variant, { value: raw })), raw, `initial ${raw}`);
|
|
2059
|
+
const dyn = track(await make(variant));
|
|
2060
|
+
dyn.setAttribute('value', raw);
|
|
2061
|
+
await dyn.updateComplete;
|
|
2062
|
+
expectPublishedSeed(dyn, raw, `attr ${raw}`);
|
|
2063
|
+
}
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
for (const [raw, expected] of CASES) {
|
|
2067
|
+
const init = track(await make(variant, { value: raw }));
|
|
2068
|
+
expect(ymd(init.value), `initial ${raw}`).to.deep.equal(expected);
|
|
2069
|
+
const dyn = track(await make(variant));
|
|
2070
|
+
dyn.setAttribute('value', raw);
|
|
2071
|
+
await dyn.updateComplete;
|
|
2072
|
+
expect(ymd(dyn.value), `attr ${raw}`).to.deep.equal(expected);
|
|
2073
|
+
const prop = track(await make(variant, { value: '2026-06-15' }));
|
|
2074
|
+
prop.valueAttribute = raw;
|
|
2075
|
+
await prop.updateComplete;
|
|
2076
|
+
expect(ymd(prop.value), `prop ${raw}`).to.deep.equal(expected);
|
|
2077
|
+
expect([prop.currentYear, prop.currentMonth + 1], `${raw} grid`).to.deep.equal([expected[0], expected[1]]);
|
|
2078
|
+
}
|
|
2079
|
+
});
|
|
2080
|
+
it(`C2 [${variant}]. single value: valid date-only values are unchanged`, async () => {
|
|
2081
|
+
if (variant === 'default') {
|
|
2082
|
+
for (const [raw] of VALID)
|
|
2083
|
+
expectPublishedSeed(track(await make(variant, { value: raw })), raw, raw);
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
for (const [raw, expected] of VALID) {
|
|
2087
|
+
const el = track(await make(variant, { value: raw }));
|
|
2088
|
+
expect(ymd(el.value), raw).to.deep.equal(expected);
|
|
2089
|
+
expect(hms(el.value).slice(0, 3), `${raw} midnight`).to.deep.equal([0, 0, 0]);
|
|
2090
|
+
}
|
|
2091
|
+
});
|
|
2092
|
+
it(`C3 [${variant}]. range bounds: overflow days clamp for start and end (initial and dynamic); end stays 23:59:59.999`, async () => {
|
|
2093
|
+
if (variant === 'default') {
|
|
2094
|
+
for (const [raw] of CASES) {
|
|
2095
|
+
const init = track(await make(variant, { range: '', value: json(raw, raw) }));
|
|
2096
|
+
expect(init.startDate.getTime(), `start ${raw}`).to.equal(published.bound(raw).getTime());
|
|
2097
|
+
expect(init.endDate.getTime(), `end ${raw}`).to.equal(published.bound(raw).getTime());
|
|
2098
|
+
const dyn = track(await make(variant, { range: '' }));
|
|
2099
|
+
dyn.setAttribute('value', json('2026-01-10', raw));
|
|
2100
|
+
await dyn.updateComplete;
|
|
2101
|
+
expect(dyn.endDate.getTime(), `dyn end ${raw}`).to.equal(published.bound(raw).getTime());
|
|
2102
|
+
}
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2105
|
+
for (const [raw, expected] of CASES) {
|
|
2106
|
+
const init = track(await make(variant, { range: '', value: json(raw, raw) }));
|
|
2107
|
+
expect(ymd(init.startDate), `start ${raw}`).to.deep.equal(expected);
|
|
2108
|
+
expect(ymd(init.endDate), `end ${raw}`).to.deep.equal(expected);
|
|
2109
|
+
expect(hms(init.startDate)).to.deep.equal([0, 0, 0, 0]);
|
|
2110
|
+
expect(hms(init.endDate)).to.deep.equal([23, 59, 59, 999]);
|
|
2111
|
+
const dyn = track(await make(variant, { range: '' }));
|
|
2112
|
+
dyn.setAttribute('value', json('2026-01-10', raw));
|
|
2113
|
+
await dyn.updateComplete;
|
|
2114
|
+
expect(ymd(dyn.startDate), 'dyn start').to.deep.equal([2026, 1, 10]);
|
|
2115
|
+
expect(ymd(dyn.endDate), `dyn end ${raw}`).to.deep.equal(expected);
|
|
2116
|
+
}
|
|
2117
|
+
});
|
|
2118
|
+
it(`C4 [${variant}]. range bounds: valid date-only ranges are unchanged`, async () => {
|
|
2119
|
+
if (variant === 'default') {
|
|
2120
|
+
for (const [raw] of VALID) {
|
|
2121
|
+
const el = track(await make(variant, { range: '', value: json(raw, raw) }));
|
|
2122
|
+
expect(el.startDate.getTime(), raw).to.equal(published.bound(raw).getTime());
|
|
2123
|
+
expect(el.endDate.getTime(), raw).to.equal(published.bound(raw).getTime());
|
|
2124
|
+
}
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
for (const [raw, expected] of VALID) {
|
|
2128
|
+
const el = track(await make(variant, { range: '', value: json(raw, raw) }));
|
|
2129
|
+
expect(ymd(el.startDate), raw).to.deep.equal(expected);
|
|
2130
|
+
expect(ymd(el.endDate), raw).to.deep.equal(expected);
|
|
2131
|
+
}
|
|
2132
|
+
});
|
|
2133
|
+
it(`C5 [${variant}]. genuinely invalid date-only strings (month 13, month 0, day 0) are still not seeded`, async () => {
|
|
2134
|
+
for (const raw of ['2026-13-01', '2026-00-10', '2026-06-00']) {
|
|
2135
|
+
const single = track(await make(variant, { value: raw }));
|
|
2136
|
+
expect(single.value ?? null, `single ${raw}`).to.equal(null);
|
|
2137
|
+
const rng = track(await make(variant, { range: '', value: json(raw, raw) }));
|
|
2138
|
+
expect(isNaN(rng.startDate.getTime()), `range ${raw}`).to.equal(true);
|
|
2139
|
+
}
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
const BIG_DAYS = [
|
|
2143
|
+
['2026-02-32', [2026, 2, 28]], ['2026-02-45', [2026, 2, 28]], ['2026-02-99', [2026, 2, 28]],
|
|
2144
|
+
['2024-02-32', [2024, 2, 29]], ['2024-02-45', [2024, 2, 29]], ['2024-02-99', [2024, 2, 29]],
|
|
2145
|
+
['2026-04-32', [2026, 4, 30]], ['2026-04-45', [2026, 4, 30]], ['2026-04-99', [2026, 4, 30]],
|
|
2146
|
+
['2026-01-32', [2026, 1, 31]], ['2026-01-45', [2026, 1, 31]], ['2026-01-99', [2026, 1, 31]],
|
|
2147
|
+
['2026-12-99', [2026, 12, 31]], ['0050-02-45', [50, 2, 28]],
|
|
2148
|
+
];
|
|
2149
|
+
for (const variant of ['default', 'date-time-picker']) {
|
|
2150
|
+
it(`C8 [${variant}]. days 32, 45 and 99 clamp to the month end: single (initial, attribute, property) and range bounds`, async () => {
|
|
2151
|
+
if (variant === 'default') {
|
|
2152
|
+
for (const [raw] of BIG_DAYS) {
|
|
2153
|
+
expectPublishedSeed(track(await make(variant, { value: raw })), raw, `initial ${raw}`);
|
|
2154
|
+
const rng = track(await make(variant, { range: '', value: json(raw, raw) }));
|
|
2155
|
+
const exp = published.bound(raw);
|
|
2156
|
+
expect(Number.isNaN(rng.startDate.getTime()) ? NaN : rng.startDate.getTime(), `range start ${raw}`)
|
|
2157
|
+
.to.deep.equal(Number.isNaN(exp.getTime()) ? NaN : exp.getTime());
|
|
2158
|
+
}
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
for (const [raw, expected] of BIG_DAYS) {
|
|
2162
|
+
const init = track(await make(variant, { value: raw }));
|
|
2163
|
+
expect(ymd(init.value), `initial ${raw}`).to.deep.equal(expected);
|
|
2164
|
+
const dyn = track(await make(variant));
|
|
2165
|
+
dyn.setAttribute('value', raw);
|
|
2166
|
+
await dyn.updateComplete;
|
|
2167
|
+
expect(ymd(dyn.value), `attr ${raw}`).to.deep.equal(expected);
|
|
2168
|
+
const prop = track(await make(variant, { value: '2026-06-15' }));
|
|
2169
|
+
prop.valueAttribute = raw;
|
|
2170
|
+
await prop.updateComplete;
|
|
2171
|
+
expect(ymd(prop.value), `prop ${raw}`).to.deep.equal(expected);
|
|
2172
|
+
const rng = track(await make(variant, { range: '', value: json(raw, raw) }));
|
|
2173
|
+
expect(ymd(rng.startDate), `range start ${raw}`).to.deep.equal(expected);
|
|
2174
|
+
expect(ymd(rng.endDate), `range end ${raw}`).to.deep.equal(expected);
|
|
2175
|
+
expect(hms(rng.startDate)).to.deep.equal([0, 0, 0, 0]);
|
|
2176
|
+
expect(hms(rng.endDate)).to.deep.equal([23, 59, 59, 999]);
|
|
2177
|
+
const rd = track(await make(variant, { range: '' }));
|
|
2178
|
+
rd.setAttribute('value', json('2026-01-10', raw));
|
|
2179
|
+
await rd.updateComplete;
|
|
2180
|
+
expect(ymd(rd.endDate), `range dyn ${raw}`).to.deep.equal(expected);
|
|
2181
|
+
}
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
it('C6. non-date-only strings keep exactly their previous parse (offset-less, Z, offset) in the default variant', async () => {
|
|
2185
|
+
for (const raw of ['2026-02-28T10:15:00', '2026-06-10T08:30:00Z', '2026-06-10T08:30:00+05:30']) {
|
|
2186
|
+
const el = track(await make('default', { value: raw }));
|
|
2187
|
+
expectPublishedSeed(el, raw, raw); // published: shifted by the host offset (the variant keeps its own parsers)
|
|
2188
|
+
}
|
|
2189
|
+
});
|
|
2190
|
+
it('C7. the clamped bound still feeds B3: only the date-time-picker variant navigates to the start month', async () => {
|
|
2191
|
+
const dtp = track(await make('date-time-picker', { range: '', value: json('2026-02-31', '2026-04-31') }));
|
|
2192
|
+
expect([dtp.currentYear, dtp.currentMonth]).to.deep.equal([2026, 1]);
|
|
2193
|
+
const def = track(await make('default', { range: '', value: json('2026-02-31', '2026-04-31') }));
|
|
2194
|
+
expect([def.currentYear, def.currentMonth]).to.deep.equal([new Date().getFullYear(), new Date().getMonth()]);
|
|
2195
|
+
});
|
|
2196
|
+
});
|
|
2197
|
+
describe('NileCalendar Reset restores the developer-configured timezone', () => {
|
|
2198
|
+
const make = (attrs = '') => {
|
|
2199
|
+
const host = document.createElement('div');
|
|
2200
|
+
host.innerHTML = `<nile-calendar variant="date-time-picker" ${attrs}></nile-calendar>`;
|
|
2201
|
+
document.body.appendChild(host);
|
|
2202
|
+
const el = host.firstElementChild;
|
|
2203
|
+
return { host, el };
|
|
2204
|
+
};
|
|
2205
|
+
const created = [];
|
|
2206
|
+
const mount = async (attrs = '') => {
|
|
2207
|
+
const { host, el } = make(attrs);
|
|
2208
|
+
created.push(host);
|
|
2209
|
+
await el.updateComplete;
|
|
2210
|
+
return el;
|
|
2211
|
+
};
|
|
2212
|
+
afterEach(() => { created.splice(0).forEach(h => h.remove()); });
|
|
2213
|
+
const pick = async (el, tz) => {
|
|
2214
|
+
el.handleTimezoneChange(new CustomEvent('nile-change', { detail: { value: tz } }));
|
|
2215
|
+
await el.updateComplete;
|
|
2216
|
+
};
|
|
2217
|
+
const reset = async (el) => {
|
|
2218
|
+
el.shadowRoot.querySelector('.clear-button').click();
|
|
2219
|
+
await el.updateComplete;
|
|
2220
|
+
};
|
|
2221
|
+
const firstDay = (el) => el.shadowRoot.querySelector('.day_date:not(.filler)').click();
|
|
2222
|
+
it('R1. default UTC -> pick London -> Reset -> UTC', async () => {
|
|
2223
|
+
const el = await mount();
|
|
2224
|
+
await pick(el, 'Europe/London');
|
|
2225
|
+
expect(el.timezone).to.equal('Europe/London');
|
|
2226
|
+
await reset(el);
|
|
2227
|
+
expect(el.timezone).to.equal('UTC');
|
|
2228
|
+
});
|
|
2229
|
+
it('R2. configured London -> pick Tokyo -> Reset -> London', async () => {
|
|
2230
|
+
const el = await mount('timezone="Europe/London"');
|
|
2231
|
+
expect(el.timezone).to.equal('Europe/London');
|
|
2232
|
+
await pick(el, 'Asia/Tokyo');
|
|
2233
|
+
expect(el.timezone).to.equal('Asia/Tokyo');
|
|
2234
|
+
await reset(el);
|
|
2235
|
+
expect(el.timezone).to.equal('Europe/London');
|
|
2236
|
+
});
|
|
2237
|
+
it('R3. configured Kolkata -> pick UTC -> Reset -> Kolkata', async () => {
|
|
2238
|
+
const el = await mount('timezone="Asia/Kolkata"');
|
|
2239
|
+
await pick(el, 'UTC');
|
|
2240
|
+
expect(el.timezone).to.equal('UTC');
|
|
2241
|
+
await reset(el);
|
|
2242
|
+
expect(el.timezone).to.equal('Asia/Kolkata');
|
|
2243
|
+
});
|
|
2244
|
+
it('R4. a dynamic consumer change is the new configured zone; Reset restores the latest', async () => {
|
|
2245
|
+
const el = await mount('timezone="Europe/London"');
|
|
2246
|
+
el.timezone = 'America/New_York';
|
|
2247
|
+
await el.updateComplete;
|
|
2248
|
+
await pick(el, 'Asia/Tokyo');
|
|
2249
|
+
await reset(el);
|
|
2250
|
+
expect(el.timezone).to.equal('America/New_York');
|
|
2251
|
+
el.setAttribute('timezone', 'Asia/Kolkata');
|
|
2252
|
+
await el.updateComplete;
|
|
2253
|
+
await reset(el);
|
|
2254
|
+
expect(el.timezone).to.equal('Asia/Kolkata');
|
|
2255
|
+
});
|
|
2256
|
+
it('R5. Reset after Set to now (host zone) restores the configured zone; Set to now still adopts the host zone', async () => {
|
|
2257
|
+
const el = await mount('timezone="Europe/London"');
|
|
2258
|
+
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
2259
|
+
el.shadowRoot.querySelector('.tp-set-to-now-button').click();
|
|
2260
|
+
await el.updateComplete;
|
|
2261
|
+
expect(el.timezone).to.equal(host);
|
|
2262
|
+
await reset(el);
|
|
2263
|
+
expect(el.timezone).to.equal('Europe/London');
|
|
2264
|
+
});
|
|
2265
|
+
it('R6. Use local timezone does not overwrite the configured zone', async () => {
|
|
2266
|
+
const el = await mount('timezone="Asia/Kolkata"');
|
|
2267
|
+
el.shadowRoot.querySelector('.tp-timezone-local-link').click();
|
|
2268
|
+
await el.updateComplete;
|
|
2269
|
+
await reset(el);
|
|
2270
|
+
expect(el.timezone).to.equal('Asia/Kolkata');
|
|
2271
|
+
});
|
|
2272
|
+
it('R7. a bracketed value seeds its zone internally; Reset restores the configured zone', async () => {
|
|
2273
|
+
const el = await mount('timezone="Europe/London" value="2026-07-15T14:30:05.250[GMT+09:00 Asia/Tokyo]"');
|
|
2274
|
+
expect(el.timezone).to.equal('Asia/Tokyo');
|
|
2275
|
+
await reset(el);
|
|
2276
|
+
expect(el.timezone).to.equal('Europe/London');
|
|
2277
|
+
});
|
|
2278
|
+
it('R8. doubleClickUnselect uses the Reset path and restores the configured zone', async () => {
|
|
2279
|
+
const el = await mount('timezone="Europe/London" value="2026-07-15T14:30:05.250[GMT+01:00 Europe/London]"');
|
|
2280
|
+
el.doubleClickUnselect = true;
|
|
2281
|
+
await pick(el, 'Asia/Tokyo');
|
|
2282
|
+
await el.updateComplete;
|
|
2283
|
+
const sel = el.shadowRoot.querySelector('.day_date.selected-date:not(.filler)');
|
|
2284
|
+
if (sel) {
|
|
2285
|
+
sel.click();
|
|
2286
|
+
await el.updateComplete;
|
|
2287
|
+
expect(el.value).to.be.null;
|
|
2288
|
+
}
|
|
2289
|
+
else {
|
|
2290
|
+
firstDay(el);
|
|
2291
|
+
await el.updateComplete;
|
|
2292
|
+
el.handleReset();
|
|
2293
|
+
await el.updateComplete;
|
|
2294
|
+
}
|
|
2295
|
+
expect(el.timezone).to.equal('Europe/London');
|
|
2296
|
+
});
|
|
2297
|
+
it('R9. works with the timezone field hidden via hide-time-fields', async () => {
|
|
2298
|
+
const host = document.createElement('div');
|
|
2299
|
+
host.innerHTML = `<nile-calendar variant="date-time-picker" timezone="Europe/London" hide-time-fields='["timezone"]'></nile-calendar>`;
|
|
2300
|
+
document.body.appendChild(host);
|
|
2301
|
+
created.push(host);
|
|
2302
|
+
const el = host.firstElementChild;
|
|
2303
|
+
await el.updateComplete;
|
|
2304
|
+
expect(el.shadowRoot.querySelector('.tp-timezone-select')).to.not.exist;
|
|
2305
|
+
el.shadowRoot.querySelector('.tp-set-to-now-button')?.click();
|
|
2306
|
+
await el.updateComplete;
|
|
2307
|
+
await reset(el);
|
|
2308
|
+
expect(el.timezone).to.equal('Europe/London');
|
|
2309
|
+
});
|
|
2310
|
+
it('R10. Set to now still adopts the host zone and absolute Apply emits the selected zone', async () => {
|
|
2311
|
+
const el = await mount('timezone="Europe/London"');
|
|
2312
|
+
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
2313
|
+
let detail = null;
|
|
2314
|
+
el.addEventListener('nile-change', ((e) => { detail = e.detail; }));
|
|
2315
|
+
el.shadowRoot.querySelector('.tp-set-to-now-button').click();
|
|
2316
|
+
await el.updateComplete;
|
|
2317
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
2318
|
+
expect(detail.timezone).to.equal(host);
|
|
2319
|
+
});
|
|
2320
|
+
it('R11. relative Apply still emits UTC with a configured non-UTC zone', async () => {
|
|
2321
|
+
const el = await mount('timezone="Asia/Tokyo"');
|
|
2322
|
+
el.type = 'relative';
|
|
2323
|
+
await el.updateComplete;
|
|
2324
|
+
let detail = null;
|
|
2325
|
+
el.addEventListener('nile-change', ((e) => { detail = e.detail; }));
|
|
2326
|
+
el.shadowRoot.querySelector('.duration__value').click();
|
|
2327
|
+
await el.updateComplete;
|
|
2328
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
2329
|
+
expect(detail.timezone).to.equal('UTC');
|
|
2330
|
+
expect(el.timezone).to.equal('Asia/Tokyo');
|
|
2331
|
+
});
|
|
2332
|
+
});
|
|
2333
|
+
describe('NileCalendar hideTimeFields is the only time-field visibility API', () => {
|
|
2334
|
+
const created = [];
|
|
2335
|
+
const mount = async (attrs = '') => {
|
|
2336
|
+
const host = document.createElement('div');
|
|
2337
|
+
host.innerHTML = `<nile-calendar variant="date-time-picker" ${attrs}></nile-calendar>`;
|
|
2338
|
+
document.body.appendChild(host);
|
|
2339
|
+
created.push(host);
|
|
2340
|
+
const el = host.firstElementChild;
|
|
2341
|
+
await el.updateComplete;
|
|
2342
|
+
return el;
|
|
2343
|
+
};
|
|
2344
|
+
afterEach(() => { created.splice(0).forEach(h => h.remove()); });
|
|
2345
|
+
const labels = (el) => Array.from(el.shadowRoot.querySelectorAll('.tp-time-field')).map((n) => n.getAttribute('label'));
|
|
2346
|
+
const ALL = ['Hours (24H)', 'Minutes', 'Seconds', 'Milliseconds'];
|
|
2347
|
+
const has = (el, sel) => !!el.shadowRoot.querySelector(sel);
|
|
2348
|
+
it('H1. there is no timeFields property any more', async () => {
|
|
2349
|
+
const el = await mount();
|
|
2350
|
+
expect('timeFields' in el).to.be.false;
|
|
2351
|
+
expect(NileCalendarCtor().elementProperties.has('timeFields')).to.be.false;
|
|
2352
|
+
});
|
|
2353
|
+
it('H2. by default every field and the timezone block render', async () => {
|
|
2354
|
+
const el = await mount();
|
|
2355
|
+
expect(labels(el)).to.deep.equal(ALL);
|
|
2356
|
+
expect(has(el, '.tp-timezone')).to.be.true;
|
|
2357
|
+
expect(has(el, '.tp-time-fields--stacked')).to.be.false;
|
|
2358
|
+
expect(has(el, '.base--tp-fewfields')).to.be.false;
|
|
2359
|
+
});
|
|
2360
|
+
it('H3. each single hidden field disappears alone', async () => {
|
|
2361
|
+
const el = await mount();
|
|
2362
|
+
const map = { hours: 'Hours (24H)', minutes: 'Minutes', seconds: 'Seconds', milliseconds: 'Milliseconds' };
|
|
2363
|
+
for (const key of Object.keys(map)) {
|
|
2364
|
+
el.hideTimeFields = [key];
|
|
2365
|
+
await el.updateComplete;
|
|
2366
|
+
expect(labels(el), key).to.deep.equal(ALL.filter(l => l !== map[key]));
|
|
2367
|
+
expect(has(el, '.tp-timezone'), key).to.be.true;
|
|
2368
|
+
}
|
|
2369
|
+
el.hideTimeFields = ['timezone'];
|
|
2370
|
+
await el.updateComplete;
|
|
2371
|
+
expect(labels(el)).to.deep.equal(ALL);
|
|
2372
|
+
expect(has(el, '.tp-timezone')).to.be.false;
|
|
2373
|
+
});
|
|
2374
|
+
it('H4. fewer than three time fields with no timezone stacks the layout (few-fields classes)', async () => {
|
|
2375
|
+
const el = await mount(`hide-time-fields='["seconds","milliseconds","timezone"]'`);
|
|
2376
|
+
expect(labels(el)).to.deep.equal(['Hours (24H)', 'Minutes']);
|
|
2377
|
+
expect(has(el, '.tp-time-fields--stacked')).to.be.true;
|
|
2378
|
+
expect(has(el, '.base--tp-fewfields')).to.be.true;
|
|
2379
|
+
el.hideTimeFields = ['seconds', 'milliseconds']; // timezone back: not stacked
|
|
2380
|
+
await el.updateComplete;
|
|
2381
|
+
expect(has(el, '.tp-time-fields--stacked')).to.be.false;
|
|
2382
|
+
expect(has(el, '.base--tp-fewfields')).to.be.false;
|
|
2383
|
+
});
|
|
2384
|
+
it('H5. all fields hidden: empty time row, no timezone, same layout classes as before', async () => {
|
|
2385
|
+
const el = await mount(`hide-time-fields='["hours","minutes","seconds","milliseconds","timezone"]'`);
|
|
2386
|
+
expect(labels(el)).to.deep.equal([]);
|
|
2387
|
+
expect(has(el, '.tp-timezone')).to.be.false;
|
|
2388
|
+
expect(has(el, '.tp-time-fields')).to.be.true;
|
|
2389
|
+
expect(has(el, '.tp-time-fields--stacked')).to.be.true;
|
|
2390
|
+
expect(has(el, '.base--tp-fewfields')).to.be.true;
|
|
2391
|
+
});
|
|
2392
|
+
it('H6. the four time fields hidden but timezone shown renders an empty, non-stacked row', async () => {
|
|
2393
|
+
const el = await mount(`hide-time-fields='["hours","minutes","seconds","milliseconds"]'`);
|
|
2394
|
+
expect(labels(el)).to.deep.equal([]);
|
|
2395
|
+
expect(has(el, '.tp-timezone')).to.be.true;
|
|
2396
|
+
expect(has(el, '.tp-time-fields--stacked')).to.be.false;
|
|
2397
|
+
});
|
|
2398
|
+
it('H7. HTML attribute and property forms agree, and dynamic updates re-render both ways', async () => {
|
|
2399
|
+
const attr = await mount(`hide-time-fields='["seconds"]'`);
|
|
2400
|
+
const prop = await mount();
|
|
2401
|
+
prop.hideTimeFields = ['seconds'];
|
|
2402
|
+
await prop.updateComplete;
|
|
2403
|
+
expect(labels(attr)).to.deep.equal(labels(prop));
|
|
2404
|
+
expect(labels(attr)).to.deep.equal(['Hours (24H)', 'Minutes', 'Milliseconds']);
|
|
2405
|
+
attr.setAttribute('hide-time-fields', '["minutes"]');
|
|
2406
|
+
await attr.updateComplete;
|
|
2407
|
+
expect(labels(attr)).to.deep.equal(['Hours (24H)', 'Seconds', 'Milliseconds']);
|
|
2408
|
+
attr.hideTimeFields = [];
|
|
2409
|
+
await attr.updateComplete;
|
|
2410
|
+
expect(labels(attr)).to.deep.equal(ALL);
|
|
2411
|
+
});
|
|
2412
|
+
it('H8. unknown keys in the list are ignored', async () => {
|
|
2413
|
+
const el = await mount(`hide-time-fields='["bogus"]'`);
|
|
2414
|
+
expect(labels(el)).to.deep.equal(ALL);
|
|
2415
|
+
expect(has(el, '.tp-timezone')).to.be.true;
|
|
2416
|
+
});
|
|
2417
|
+
it('H9. invalid JSON and valid-JSON non-arrays count as an empty list and never crash rendering', async () => {
|
|
2418
|
+
for (const raw of ['{seconds:false}', 'seconds', '5', '{"a":1}', 'null', 'true', '"seconds"']) {
|
|
2419
|
+
const el = await mount(`hide-time-fields='${raw}'`);
|
|
2420
|
+
expect(labels(el), raw).to.deep.equal(ALL);
|
|
2421
|
+
expect(has(el, '.tp-timezone'), raw).to.be.true;
|
|
2422
|
+
}
|
|
2423
|
+
const el = await mount();
|
|
2424
|
+
for (const bad of [5, { a: 1 }, 'seconds', null, undefined]) {
|
|
2425
|
+
el.hideTimeFields = bad;
|
|
2426
|
+
await el.updateComplete;
|
|
2427
|
+
expect(labels(el), String(bad)).to.deep.equal(ALL);
|
|
2428
|
+
}
|
|
2429
|
+
});
|
|
2430
|
+
it('H10. the default variant ignores hideTimeFields', async () => {
|
|
2431
|
+
const host = document.createElement('div');
|
|
2432
|
+
host.innerHTML = `<nile-calendar hide-time-fields='["seconds"]'></nile-calendar>`;
|
|
2433
|
+
document.body.appendChild(host);
|
|
2434
|
+
created.push(host);
|
|
2435
|
+
const el = host.firstElementChild;
|
|
2436
|
+
await el.updateComplete;
|
|
2437
|
+
expect(has(el, '.tp-time-fields')).to.be.false;
|
|
2438
|
+
});
|
|
2439
|
+
});
|
|
2440
|
+
function NileCalendarCtor() { return customElements.get('nile-calendar'); }
|
|
2441
|
+
describe('NileCalendar structuredInput scope and variant switching', () => {
|
|
2442
|
+
const created = [];
|
|
2443
|
+
const mount = async (attrs) => {
|
|
2444
|
+
const host = document.createElement('div');
|
|
2445
|
+
host.innerHTML = `<nile-calendar ${attrs}></nile-calendar>`;
|
|
2446
|
+
document.body.appendChild(host);
|
|
2447
|
+
created.push(host);
|
|
2448
|
+
const el = host.firstElementChild;
|
|
2449
|
+
await el.updateComplete;
|
|
2450
|
+
return el;
|
|
2451
|
+
};
|
|
2452
|
+
afterEach(() => { created.splice(0).forEach(h => h.remove()); });
|
|
2453
|
+
const manual = (el) => Array.from(el.shadowRoot.querySelectorAll('.calender-input nile-input'));
|
|
2454
|
+
const bound = (el) => el.boundMaskInputs;
|
|
2455
|
+
const settle = async (el) => { await el.updateComplete; await el.updateComplete; };
|
|
2456
|
+
/** keydown-only typing: a masked input blocks the key (preventDefault) and writes the display itself */
|
|
2457
|
+
const type = async (ni, text) => {
|
|
2458
|
+
await ni.updateComplete;
|
|
2459
|
+
for (const ch of text) {
|
|
2460
|
+
ni.dispatchEvent(new KeyboardEvent('keydown', { key: ch, bubbles: true, composed: true, cancelable: true }));
|
|
2461
|
+
}
|
|
2462
|
+
return ni.input.value;
|
|
2463
|
+
};
|
|
2464
|
+
const RANGE = 'range showManualInputs structuredInput dateFormat="MM/DD/YYYY"';
|
|
2465
|
+
it('SI1. default variant with range + showManualInputs + structuredInput masks all four manual inputs', async () => {
|
|
2466
|
+
const el = await mount(RANGE);
|
|
2467
|
+
expect(manual(el).length).to.equal(4);
|
|
2468
|
+
expect(bound(el).length).to.equal(4);
|
|
2469
|
+
expect(await type(el.shadowRoot.querySelector('.date-input'), '12312025')).to.equal('12/31/2025');
|
|
2470
|
+
expect(await type(el.shadowRoot.querySelector('.time-input'), '123045')).to.equal('12:30:45');
|
|
2471
|
+
});
|
|
2472
|
+
it('SI2. the date-time-picker variant has no manual inputs, so structuredInput binds nothing', async () => {
|
|
2473
|
+
const el = await mount(`variant="date-time-picker" ${RANGE}`);
|
|
2474
|
+
expect(el.shadowRoot.querySelector('.calender-input')).to.be.null;
|
|
2475
|
+
expect(manual(el).length).to.equal(0);
|
|
2476
|
+
expect(bound(el).length).to.equal(0);
|
|
2477
|
+
});
|
|
2478
|
+
it('SI3. structuredInput on a single (non-range) default calendar is a no-op: there are no manual inputs to mask', async () => {
|
|
2479
|
+
const el = await mount('structuredInput');
|
|
2480
|
+
expect(manual(el).length).to.equal(0);
|
|
2481
|
+
expect(bound(el).length).to.equal(0);
|
|
2482
|
+
});
|
|
2483
|
+
it('SI4. switching date-time-picker -> default at runtime binds the mask to the newly rendered manual inputs', async () => {
|
|
2484
|
+
const el = await mount(`variant="date-time-picker" ${RANGE}`);
|
|
2485
|
+
expect(bound(el).length).to.equal(0);
|
|
2486
|
+
el.variant = 'default';
|
|
2487
|
+
await settle(el);
|
|
2488
|
+
expect(manual(el).length).to.equal(4);
|
|
2489
|
+
expect(bound(el).length).to.equal(4);
|
|
2490
|
+
expect(await type(el.shadowRoot.querySelector('.date-input'), '12312025')).to.equal('12/31/2025');
|
|
2491
|
+
});
|
|
2492
|
+
it('SI5. switching default -> date-time-picker unbinds: nothing is left bound, and no detached input is retained', async () => {
|
|
2493
|
+
const el = await mount(RANGE);
|
|
2494
|
+
const before = bound(el).slice();
|
|
2495
|
+
expect(before.length).to.equal(4);
|
|
2496
|
+
el.variant = 'date-time-picker';
|
|
2497
|
+
await settle(el);
|
|
2498
|
+
expect(bound(el).length).to.equal(0);
|
|
2499
|
+
expect(manual(el).length).to.equal(0);
|
|
2500
|
+
expect(before.every(n => !n.isConnected)).to.be.true; // the old inputs are gone and nothing references them
|
|
2501
|
+
});
|
|
2502
|
+
it('SI6. repeated switches never duplicate or leak bindings, and typing is masked exactly once', async () => {
|
|
2503
|
+
const el = await mount(RANGE);
|
|
2504
|
+
for (let i = 0; i < 4; i++) {
|
|
2505
|
+
el.variant = 'date-time-picker';
|
|
2506
|
+
await settle(el);
|
|
2507
|
+
expect(bound(el).length, `round ${i} dtp`).to.equal(0);
|
|
2508
|
+
el.variant = 'default';
|
|
2509
|
+
await settle(el);
|
|
2510
|
+
expect(bound(el).length, `round ${i} default`).to.equal(4);
|
|
2511
|
+
expect(bound(el).every(n => n.isConnected), `round ${i} connected`).to.be.true;
|
|
2512
|
+
}
|
|
2513
|
+
const di = el.shadowRoot.querySelector('.date-input');
|
|
2514
|
+
expect(await type(di, '12312025')).to.equal('12/31/2025'); // a duplicate listener would double every digit
|
|
2515
|
+
});
|
|
2516
|
+
it('SI7. turning structuredInput off at runtime leaves the manual inputs free-form', async () => {
|
|
2517
|
+
const el = await mount(RANGE);
|
|
2518
|
+
el.structuredInput = false;
|
|
2519
|
+
await settle(el);
|
|
2520
|
+
expect(bound(el).length).to.equal(0);
|
|
2521
|
+
expect(await type(el.shadowRoot.querySelector('.date-input'), '1')).to.equal(''); // unmasked: keydown is not intercepted
|
|
2522
|
+
});
|
|
2523
|
+
});
|
|
2524
|
+
describe('NileCalendar collapsed-height ResizeObserver lifecycle', () => {
|
|
2525
|
+
let all = [];
|
|
2526
|
+
let loopErrors = 0;
|
|
2527
|
+
let Orig;
|
|
2528
|
+
const onError = (e) => { if (/ResizeObserver loop/i.test(e.message || ''))
|
|
2529
|
+
loopErrors++; };
|
|
2530
|
+
const created = [];
|
|
2531
|
+
beforeEach(() => {
|
|
2532
|
+
all = [];
|
|
2533
|
+
loopErrors = 0;
|
|
2534
|
+
Orig = window.ResizeObserver;
|
|
2535
|
+
window.addEventListener('error', onError);
|
|
2536
|
+
// counts only the calendar's own observer: the one that watches a `.base--date-time-picker`
|
|
2537
|
+
window.ResizeObserver = class extends Orig {
|
|
2538
|
+
constructor(cb) {
|
|
2539
|
+
super(cb);
|
|
2540
|
+
this.info = { cal: false, live: false, targets: [] };
|
|
2541
|
+
all.push(this.info);
|
|
2542
|
+
}
|
|
2543
|
+
observe(t, o) {
|
|
2544
|
+
if (t.classList.contains('base--date-time-picker')) {
|
|
2545
|
+
this.info.cal = true;
|
|
2546
|
+
this.info.live = true;
|
|
2547
|
+
}
|
|
2548
|
+
this.info.targets.push(t);
|
|
2549
|
+
return super.observe(t, o);
|
|
2550
|
+
}
|
|
2551
|
+
disconnect() { this.info.live = false; return super.disconnect(); }
|
|
2552
|
+
};
|
|
2553
|
+
});
|
|
2554
|
+
afterEach(() => {
|
|
2555
|
+
window.ResizeObserver = Orig;
|
|
2556
|
+
window.removeEventListener('error', onError);
|
|
2557
|
+
created.splice(0).forEach(h => h.remove());
|
|
2558
|
+
});
|
|
2559
|
+
const live = () => all.filter(o => o.cal && o.live);
|
|
2560
|
+
const mount = async (attrs = 'variant="date-time-picker"') => {
|
|
2561
|
+
const host = document.createElement('div');
|
|
2562
|
+
host.innerHTML = `<nile-calendar ${attrs}></nile-calendar>`;
|
|
2563
|
+
document.body.appendChild(host);
|
|
2564
|
+
created.push(host);
|
|
2565
|
+
const el = host.firstElementChild;
|
|
2566
|
+
await el.updateComplete;
|
|
2567
|
+
await frames();
|
|
2568
|
+
return { host, el };
|
|
2569
|
+
};
|
|
2570
|
+
const frames = async (n = 3) => { for (let i = 0; i < n; i++)
|
|
2571
|
+
await new Promise(r => requestAnimationFrame(() => r(null))); await new Promise(r => setTimeout(r, 30)); };
|
|
2572
|
+
const baseOf = (el) => el.shadowRoot.querySelector('.base--date-time-picker');
|
|
2573
|
+
const widen = async (el, w) => {
|
|
2574
|
+
for (const k of ['--ng-width-312', '--nile-width-288px'])
|
|
2575
|
+
w ? el.style.setProperty(k, w) : el.style.removeProperty(k);
|
|
2576
|
+
await frames();
|
|
2577
|
+
};
|
|
2578
|
+
const follows = (el) => el.tpLockedHeight === `${baseOf(el).offsetHeight}px`;
|
|
2579
|
+
it('O1. initial mount: one observer on the rendered base, and the collapsed height is measured', async () => {
|
|
2580
|
+
const { el } = await mount();
|
|
2581
|
+
expect(live().length).to.equal(1);
|
|
2582
|
+
expect(live()[0].targets[0]).to.equal(baseOf(el));
|
|
2583
|
+
expect(el.tpLockedHeight).to.equal(`${baseOf(el).offsetHeight}px`);
|
|
2584
|
+
});
|
|
2585
|
+
it('O2. the default variant never creates a picker-panel observer', async () => {
|
|
2586
|
+
await mount('range');
|
|
2587
|
+
expect(all.filter(o => o.cal).length).to.equal(0);
|
|
2588
|
+
});
|
|
2589
|
+
it('O3. remove then re-attach: the observer is dropped while detached and restored, and layout changes are followed again', async () => {
|
|
2590
|
+
const { host, el } = await mount();
|
|
2591
|
+
const parent = host.parentElement;
|
|
2592
|
+
parent.removeChild(host);
|
|
2593
|
+
expect(live().length).to.equal(0);
|
|
2594
|
+
parent.appendChild(host);
|
|
2595
|
+
await el.updateComplete;
|
|
2596
|
+
await frames();
|
|
2597
|
+
expect(live().length).to.equal(1);
|
|
2598
|
+
expect(live()[0].targets.slice(-1)[0]).to.equal(baseOf(el));
|
|
2599
|
+
await widen(el, '180px');
|
|
2600
|
+
expect(follows(el), `locked=${el.tpLockedHeight} real=${baseOf(el).offsetHeight}`).to.be.true;
|
|
2601
|
+
await widen(el, null);
|
|
2602
|
+
expect(follows(el)).to.be.true;
|
|
2603
|
+
});
|
|
2604
|
+
it('O4. date-time-picker -> default -> date-time-picker, repeated: never an observer on a detached base, and layout is followed after each round', async () => {
|
|
2605
|
+
const { el } = await mount();
|
|
2606
|
+
for (let i = 0; i < 3; i++) {
|
|
2607
|
+
el.variant = 'default';
|
|
2608
|
+
await el.updateComplete;
|
|
2609
|
+
await frames();
|
|
2610
|
+
expect(live().length, `round ${i} default`).to.equal(0);
|
|
2611
|
+
expect(baseOf(el)).to.be.null;
|
|
2612
|
+
el.variant = 'date-time-picker';
|
|
2613
|
+
await el.updateComplete;
|
|
2614
|
+
await frames();
|
|
2615
|
+
expect(live().length, `round ${i} dtp`).to.equal(1);
|
|
2616
|
+
expect(live()[0].targets.slice(-1)[0], `round ${i} target`).to.equal(baseOf(el));
|
|
2617
|
+
expect(live()[0].targets.slice(-1)[0].isConnected).to.be.true;
|
|
2618
|
+
await widen(el, '180px');
|
|
2619
|
+
expect(follows(el), `round ${i} follows`).to.be.true;
|
|
2620
|
+
await widen(el, null);
|
|
2621
|
+
}
|
|
2622
|
+
});
|
|
2623
|
+
it('O5. rapid expand/collapse toggles never recreate the observer or raise a ResizeObserver loop error', async () => {
|
|
2624
|
+
const { el } = await mount();
|
|
2625
|
+
const before = all.filter(o => o.cal).length;
|
|
2626
|
+
const lockedBefore = el.tpLockedHeight;
|
|
2627
|
+
for (let i = 0; i < 40; i++)
|
|
2628
|
+
el.toggleExpanded();
|
|
2629
|
+
await el.updateComplete;
|
|
2630
|
+
await frames();
|
|
2631
|
+
for (let i = 0; i < 40; i++) {
|
|
2632
|
+
el.toggleExpanded();
|
|
2633
|
+
await new Promise(r => requestAnimationFrame(() => r(null)));
|
|
2634
|
+
}
|
|
2635
|
+
await el.updateComplete;
|
|
2636
|
+
await frames();
|
|
2637
|
+
if (el.expanded) {
|
|
2638
|
+
el.toggleExpanded();
|
|
2639
|
+
await el.updateComplete;
|
|
2640
|
+
await frames();
|
|
2641
|
+
}
|
|
2642
|
+
expect(all.filter(o => o.cal).length).to.equal(before);
|
|
2643
|
+
expect(live().length).to.equal(1);
|
|
2644
|
+
expect(loopErrors).to.equal(0);
|
|
2645
|
+
expect(el.tpLockedHeight).to.equal(lockedBefore);
|
|
2646
|
+
expect(follows(el)).to.be.true;
|
|
2647
|
+
});
|
|
2648
|
+
it('O6. the existing guard still holds: nothing is measured while expanded', async () => {
|
|
2649
|
+
const { el } = await mount();
|
|
2650
|
+
const locked = el.tpLockedHeight;
|
|
2651
|
+
el.toggleExpanded();
|
|
2652
|
+
await el.updateComplete;
|
|
2653
|
+
await frames();
|
|
2654
|
+
expect(el.expanded).to.be.true;
|
|
2655
|
+
await widen(el, '180px'); // the panel is height-locked while expanded, and the locked value is not re-measured
|
|
2656
|
+
expect(el.tpLockedHeight).to.equal(locked);
|
|
2657
|
+
el.toggleExpanded();
|
|
2658
|
+
await el.updateComplete;
|
|
2659
|
+
await frames();
|
|
2660
|
+
await widen(el, null);
|
|
2661
|
+
expect(follows(el)).to.be.true;
|
|
2662
|
+
});
|
|
2663
|
+
it('O7. 60 mount/unmount cycles leave no live observer', async () => {
|
|
2664
|
+
for (let i = 0; i < 60; i++) {
|
|
2665
|
+
const { host } = await mount();
|
|
2666
|
+
host.remove();
|
|
2667
|
+
}
|
|
2668
|
+
expect(all.filter(o => o.cal).length).to.equal(60);
|
|
2669
|
+
expect(live().length).to.equal(0);
|
|
2670
|
+
}).timeout(60000);
|
|
2671
|
+
it('O8. repeated reparenting keeps exactly one observer on the current base', async () => {
|
|
2672
|
+
const { host, el } = await mount();
|
|
2673
|
+
const other = document.createElement('div');
|
|
2674
|
+
document.body.appendChild(other);
|
|
2675
|
+
created.push(other);
|
|
2676
|
+
const origin = host.parentElement;
|
|
2677
|
+
for (let i = 0; i < 10; i++) {
|
|
2678
|
+
(i % 2 ? origin : other).appendChild(host);
|
|
2679
|
+
await el.updateComplete;
|
|
2680
|
+
await frames(1);
|
|
2681
|
+
expect(live().length, `move ${i}`).to.equal(1);
|
|
2682
|
+
}
|
|
2683
|
+
await frames();
|
|
2684
|
+
expect(live()[0].targets.slice(-1)[0]).to.equal(baseOf(el));
|
|
2685
|
+
await widen(el, '180px');
|
|
2686
|
+
expect(follows(el)).to.be.true;
|
|
2687
|
+
expect(loopErrors).to.equal(0);
|
|
2688
|
+
});
|
|
2689
|
+
it('O9. removing the element finally disconnects the observer (no retained watcher)', async () => {
|
|
2690
|
+
const { host } = await mount();
|
|
2691
|
+
expect(live().length).to.equal(1);
|
|
2692
|
+
host.remove();
|
|
2693
|
+
expect(live().length).to.equal(0);
|
|
2694
|
+
});
|
|
2695
|
+
});
|
|
2696
|
+
describe('NileCalendar date-time-picker variant: an unparseable value after a valid one clears the selection', () => {
|
|
2697
|
+
const GOOD = '2026-06-15T10:20:30.000[GMT+05:30 Asia/Kolkata]';
|
|
2698
|
+
for (const bad of ['not-a-date', '2026-13-45T10:00:00', ' ']) {
|
|
2699
|
+
it(`V ${JSON.stringify(bad)}: value and time fields reset, timezone and events untouched`, async () => {
|
|
2700
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" value=${GOOD}></nile-calendar>`);
|
|
2701
|
+
await el.updateComplete;
|
|
2702
|
+
expect(el.value.getDate(), 'precondition').to.equal(15);
|
|
2703
|
+
let changes = 0;
|
|
2704
|
+
el.addEventListener('nile-change', () => { changes++; });
|
|
2705
|
+
el.setAttribute('value', bad);
|
|
2706
|
+
await el.updateComplete;
|
|
2707
|
+
await el.updateComplete;
|
|
2708
|
+
expect(el.value).to.equal(null);
|
|
2709
|
+
expect([el.tpHours, el.tpMinutes, el.tpSeconds, el.tpMilliseconds]).to.deep.equal([null, null, null, null]);
|
|
2710
|
+
expect(el.timezone, 'zone is not touched').to.equal('Asia/Kolkata');
|
|
2711
|
+
expect(changes).to.equal(0);
|
|
2712
|
+
});
|
|
2713
|
+
}
|
|
2714
|
+
it('V the DEFAULT variant keeps its selection on an unparseable value (nile-date-picker relies on it while the user types)', async () => {
|
|
2715
|
+
const el = await fixture(html `<nile-calendar value="2026-06-15T12:00:00"></nile-calendar>`);
|
|
2716
|
+
await el.updateComplete;
|
|
2717
|
+
el.setAttribute('value', 'not-a-date');
|
|
2718
|
+
await el.updateComplete;
|
|
2719
|
+
await el.updateComplete;
|
|
2720
|
+
expect(el.value.getDate()).to.equal(15);
|
|
2721
|
+
});
|
|
2722
|
+
});
|
|
2723
|
+
describe('NileCalendar Set to now uses the machine local date, time and timezone', () => {
|
|
2724
|
+
const FROZEN = Date.UTC(2026, 5, 15, 18, 42, 37, 456); // 2026-06-15T18:42:37.456Z
|
|
2725
|
+
const HOST = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
2726
|
+
const created = [];
|
|
2727
|
+
afterEach(() => { created.splice(0).forEach(h => h.remove()); });
|
|
2728
|
+
const mount = async (attrs) => {
|
|
2729
|
+
const host = document.createElement('div');
|
|
2730
|
+
host.innerHTML = `<nile-calendar variant="date-time-picker" ${attrs}></nile-calendar>`;
|
|
2731
|
+
document.body.appendChild(host);
|
|
2732
|
+
created.push(host);
|
|
2733
|
+
const el = host.firstElementChild;
|
|
2734
|
+
await el.updateComplete;
|
|
2735
|
+
return el;
|
|
2736
|
+
};
|
|
2737
|
+
/** click Set to now with the global clock frozen (`new Date()` does not go through `Date.now`) */
|
|
2738
|
+
const setToNowAtFrozen = async (el) => {
|
|
2739
|
+
const RealDate = Date;
|
|
2740
|
+
class FrozenDate extends RealDate {
|
|
2741
|
+
constructor(...args) { if (args.length === 0)
|
|
2742
|
+
super(FROZEN);
|
|
2743
|
+
else
|
|
2744
|
+
super(...args); }
|
|
2745
|
+
static now() { return FROZEN; }
|
|
2746
|
+
}
|
|
2747
|
+
globalThis.Date = FrozenDate;
|
|
2748
|
+
try {
|
|
2749
|
+
el.shadowRoot.querySelector('.tp-set-to-now-button').click();
|
|
2750
|
+
}
|
|
2751
|
+
finally {
|
|
2752
|
+
globalThis.Date = RealDate;
|
|
2753
|
+
}
|
|
2754
|
+
await el.updateComplete;
|
|
2755
|
+
};
|
|
2756
|
+
const apply = (el) => {
|
|
2757
|
+
let detail = null;
|
|
2758
|
+
el.addEventListener('nile-change', ((e) => { detail = e.detail; }), { once: true });
|
|
2759
|
+
el.shadowRoot.querySelector('.apply-button').click();
|
|
2760
|
+
return detail;
|
|
2761
|
+
};
|
|
2762
|
+
const p2 = (n) => String(n).padStart(2, '0');
|
|
2763
|
+
// the selected zone is replaced by the machine zone, whatever it was (incl. one picked in the dropdown)
|
|
2764
|
+
for (const zone of ['UTC', 'Asia/Kolkata', 'America/New_York', 'Pacific/Kiritimati', 'Pacific/Pago_Pago']) {
|
|
2765
|
+
it(`S1. selected ${zone}: Set to now fills the machine's clock and day, switches to the machine zone, and Apply commits exactly now`, async () => {
|
|
2766
|
+
const el = await mount(`timezone="${zone}"`);
|
|
2767
|
+
await setToNowAtFrozen(el);
|
|
2768
|
+
const t = new Date(FROZEN); // read through the machine's own zone
|
|
2769
|
+
expect(el.timezone, 'zone becomes the machine zone').to.equal(HOST);
|
|
2770
|
+
expect([el.tpHours, el.tpMinutes, el.tpSeconds, el.tpMilliseconds]).to.deep.equal([t.getHours(), t.getMinutes(), t.getSeconds(), 456]);
|
|
2771
|
+
expect([el.value.getFullYear(), el.value.getMonth() + 1, el.value.getDate()], 'the machine day').to.deep.equal([t.getFullYear(), t.getMonth() + 1, t.getDate()]);
|
|
2772
|
+
expect([el.currentYear, el.currentMonth + 1]).to.deep.equal([t.getFullYear(), t.getMonth() + 1]);
|
|
2773
|
+
const detail = apply(el);
|
|
2774
|
+
expect(detail.timezone).to.equal(HOST);
|
|
2775
|
+
expect(detail.unixEpoch, 'the committed instant is exactly now').to.equal(FROZEN);
|
|
2776
|
+
expect(detail.value).to.contain(` ${HOST}]`);
|
|
2777
|
+
expect(detail.value.startsWith(`${String(t.getFullYear()).padStart(4, '0')}-${p2(t.getMonth() + 1)}-${p2(t.getDate())}T${p2(t.getHours())}:${p2(t.getMinutes())}:${p2(t.getSeconds())}.456`)).to.be.true;
|
|
2778
|
+
});
|
|
2779
|
+
}
|
|
2780
|
+
it('S2. a zone picked in the dropdown is replaced by the machine zone too, and Reset still restores the configured zone', async () => {
|
|
2781
|
+
const el = await mount('timezone="Europe/London"');
|
|
2782
|
+
el.handleTimezoneChange(new CustomEvent('nile-change', { detail: { value: 'America/New_York' } }));
|
|
2783
|
+
await el.updateComplete;
|
|
2784
|
+
await setToNowAtFrozen(el);
|
|
2785
|
+
expect(el.timezone).to.equal(HOST);
|
|
2786
|
+
expect(apply(el).unixEpoch).to.equal(FROZEN);
|
|
2787
|
+
el.shadowRoot.querySelector('.clear-button').click();
|
|
2788
|
+
await el.updateComplete;
|
|
2789
|
+
expect(el.timezone).to.equal('Europe/London');
|
|
2790
|
+
});
|
|
2791
|
+
it('S3. Set to now over a seeded instant commits now, not the old instant', async () => {
|
|
2792
|
+
const el = await mount('timezone="UTC" value="2026-01-02T03:04:05.000Z"');
|
|
2793
|
+
await setToNowAtFrozen(el);
|
|
2794
|
+
expect(apply(el).unixEpoch).to.equal(FROZEN);
|
|
2795
|
+
});
|
|
2796
|
+
it('S4. "Use local timezone" adopts the machine zone', async () => {
|
|
2797
|
+
const el = await mount('timezone="Pacific/Kiritimati"');
|
|
2798
|
+
el.shadowRoot.querySelector('.tp-timezone-local-link').click();
|
|
2799
|
+
await el.updateComplete;
|
|
2800
|
+
expect(el.timezone).to.equal(HOST);
|
|
2801
|
+
});
|
|
2802
|
+
});
|
|
2803
|
+
describe('NileCalendar relative range arithmetic (createRelativePeriod)', () => {
|
|
2804
|
+
const RealDate = Date;
|
|
2805
|
+
/** run `fn` with the global clock frozen at `ts` (`new Date()` does not go through `Date.now`) */
|
|
2806
|
+
const frozen = (ts, fn) => {
|
|
2807
|
+
class FrozenDate extends RealDate {
|
|
2808
|
+
constructor(...args) { if (args.length === 0)
|
|
2809
|
+
super(ts);
|
|
2810
|
+
else
|
|
2811
|
+
super(...args); }
|
|
2812
|
+
static now() { return ts; }
|
|
2813
|
+
}
|
|
2814
|
+
globalThis.Date = FrozenDate;
|
|
2815
|
+
try {
|
|
2816
|
+
return fn();
|
|
2817
|
+
}
|
|
2818
|
+
finally {
|
|
2819
|
+
globalThis.Date = RealDate;
|
|
2820
|
+
}
|
|
2821
|
+
};
|
|
2822
|
+
const make = async () => {
|
|
2823
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" type="relative"></nile-calendar>`);
|
|
2824
|
+
await el.updateComplete;
|
|
2825
|
+
return el;
|
|
2826
|
+
};
|
|
2827
|
+
const local = (y, m1, d, h = 12, mi = 0) => localDateFromFields(y, m1 - 1, d, h, mi).getTime();
|
|
2828
|
+
const ymdhm = (d) => [d.getFullYear(), d.getMonth() + 1, d.getDate(), d.getHours(), d.getMinutes()];
|
|
2829
|
+
/** every host-zone offset change in 2026, as the first instant on the new offset (empty on a host without DST) */
|
|
2830
|
+
const hostTransitions = () => {
|
|
2831
|
+
const out = [];
|
|
2832
|
+
let prev = new RealDate(Date.UTC(2026, 0, 1)).getTimezoneOffset();
|
|
2833
|
+
for (let t = Date.UTC(2026, 0, 1); t < Date.UTC(2027, 0, 1); t += 15 * 60000) {
|
|
2834
|
+
const off = new RealDate(t).getTimezoneOffset();
|
|
2835
|
+
if (off !== prev) {
|
|
2836
|
+
out.push(t);
|
|
2837
|
+
prev = off;
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
return out;
|
|
2841
|
+
};
|
|
2842
|
+
it('A1. minutes and hours are elapsed durations, also across the host DST transitions (host-zone independent)', async () => {
|
|
2843
|
+
const el = await make();
|
|
2844
|
+
const transitions = hostTransitions();
|
|
2845
|
+
// eslint-disable-next-line no-console
|
|
2846
|
+
console.warn(`[relative-arith] host DST transitions in 2026: ${transitions.length}`);
|
|
2847
|
+
const bad = [];
|
|
2848
|
+
for (const tr of transitions) {
|
|
2849
|
+
for (const offsetMin of [-45, -5, 5, 30, 90, 150, 200, 300]) {
|
|
2850
|
+
const now = tr + offsetMin * 60000;
|
|
2851
|
+
for (const [unit, ms] of [['minutes', 60000], ['hours', 3600000]]) {
|
|
2852
|
+
for (const n of [1, 2, 3, 6, 30, 90, 120, 180]) {
|
|
2853
|
+
const { startDate, endDate } = frozen(now, () => el.createRelativePeriod(unit, n));
|
|
2854
|
+
const elapsed = endDate.getTime() - startDate.getTime();
|
|
2855
|
+
if (elapsed !== n * ms)
|
|
2856
|
+
bad.push(`${new RealDate(now).toISOString()} last ${n} ${unit}: elapsed ${elapsed / ms} ${unit}`);
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
expect(bad.slice(0, 12), `${bad.length} wrong ranges`).to.deep.equal([]);
|
|
2862
|
+
});
|
|
2863
|
+
it('A2. minutes and hours away from any transition are exact elapsed durations', async () => {
|
|
2864
|
+
const el = await make();
|
|
2865
|
+
const now = Date.UTC(2026, 5, 15, 12, 0, 0, 0);
|
|
2866
|
+
for (const [unit, ms] of [['minutes', 60000], ['hours', 3600000]]) {
|
|
2867
|
+
const { startDate, endDate } = frozen(now, () => el.createRelativePeriod(unit, 5));
|
|
2868
|
+
expect(endDate.getTime()).to.equal(now);
|
|
2869
|
+
expect(endDate.getTime() - startDate.getTime()).to.equal(5 * ms);
|
|
2870
|
+
}
|
|
2871
|
+
});
|
|
2872
|
+
it('A3. months never overflow past the end of the shorter target month (clamped, time of day kept)', async () => {
|
|
2873
|
+
const el = await make();
|
|
2874
|
+
const cases = [
|
|
2875
|
+
[[2026, 3, 31], 1, [2026, 2, 28, 12, 0]], // 31 Mar - 1 month: 28 Feb, not 3 Mar
|
|
2876
|
+
[[2028, 3, 31], 1, [2028, 2, 29, 12, 0]], // leap year
|
|
2877
|
+
[[2026, 10, 31], 1, [2026, 9, 30, 12, 0]], // 31 Oct - 1 month: 30 Sep, not 1 Oct
|
|
2878
|
+
[[2026, 1, 31], 11, [2025, 2, 28, 12, 0]], // across a year boundary
|
|
2879
|
+
[[2026, 5, 30], 3, [2026, 2, 28, 12, 0]],
|
|
2880
|
+
[[2026, 3, 29], 1, [2026, 2, 28, 12, 0]],
|
|
2881
|
+
];
|
|
2882
|
+
for (const [[y, m, d], n, expected] of cases) {
|
|
2883
|
+
const now = local(y, m, d);
|
|
2884
|
+
const { startDate, endDate } = frozen(now, () => el.createRelativePeriod('months', n));
|
|
2885
|
+
expect(ymdhm(startDate), `${y}-${m}-${d} minus ${n} month(s)`).to.deep.equal(expected);
|
|
2886
|
+
expect(endDate.getTime(), 'end is now').to.equal(now);
|
|
2887
|
+
}
|
|
2888
|
+
});
|
|
2889
|
+
it('A4. months that do not overflow are unchanged (same day and time of day)', async () => {
|
|
2890
|
+
const el = await make();
|
|
2891
|
+
for (const [[y, m, d], n, expected] of [
|
|
2892
|
+
[[2026, 6, 15], 3, [2026, 3, 15, 12, 0]],
|
|
2893
|
+
[[2026, 6, 15], 6, [2025, 12, 15, 12, 0]],
|
|
2894
|
+
[[2026, 1, 15], 1, [2025, 12, 15, 12, 0]],
|
|
2895
|
+
[[2026, 3, 28], 1, [2026, 2, 28, 12, 0]],
|
|
2896
|
+
]) {
|
|
2897
|
+
const { startDate } = frozen(local(y, m, d), () => el.createRelativePeriod('months', n));
|
|
2898
|
+
expect(ymdhm(startDate), `${y}-${m}-${d} minus ${n}`).to.deep.equal(expected);
|
|
2899
|
+
}
|
|
2900
|
+
});
|
|
2901
|
+
it('A5. days and weeks keep their calendar-day meaning (same wall-clock time N calendar days ago)', async () => {
|
|
2902
|
+
const el = await make();
|
|
2903
|
+
const now = local(2026, 6, 15);
|
|
2904
|
+
expect(ymdhm(frozen(now, () => el.createRelativePeriod('days', 7)).startDate)).to.deep.equal([2026, 6, 8, 12, 0]);
|
|
2905
|
+
expect(ymdhm(frozen(now, () => el.createRelativePeriod('weeks', 2)).startDate)).to.deep.equal([2026, 6, 1, 12, 0]);
|
|
2906
|
+
expect(ymdhm(frozen(local(2026, 3, 3), () => el.createRelativePeriod('days', 5)).startDate)).to.deep.equal([2026, 2, 26, 12, 0]);
|
|
2907
|
+
});
|
|
2908
|
+
});
|
|
2909
|
+
describe('NileCalendar relative duration overflow (Finding #8): a duration that cannot be a Date never becomes a selection', () => {
|
|
2910
|
+
const VARIANTS = ['default', 'date-time-picker'];
|
|
2911
|
+
const UNITS = ['minutes', 'hours', 'days', 'weeks', 'months'];
|
|
2912
|
+
/** far past the representable range for every unit (the largest valid value per unit is at most ~1.4e11 minutes) */
|
|
2913
|
+
const OVERFLOW = { minutes: 999999999999, hours: 99999999999, days: 999999999, weeks: 999999999, months: 999999999 };
|
|
2914
|
+
const make = async (variant) => {
|
|
2915
|
+
const el = variant === 'default'
|
|
2916
|
+
? await fixture(html `<nile-calendar range type="relative"></nile-calendar>`)
|
|
2917
|
+
: await fixture(html `<nile-calendar variant="date-time-picker" type="relative"></nile-calendar>`);
|
|
2918
|
+
await el.updateComplete;
|
|
2919
|
+
return el;
|
|
2920
|
+
};
|
|
2921
|
+
const durationInput = (el) => el.shadowRoot.querySelector('.duration-input');
|
|
2922
|
+
const unitSelect = (el) => el.shadowRoot.querySelector('.time-input');
|
|
2923
|
+
const applyBtn = (el) => el.shadowRoot.querySelector('.apply-button');
|
|
2924
|
+
const isDisabled = (el) => applyBtn(el).hasAttribute('disabled');
|
|
2925
|
+
const fire = (target, value) => target.dispatchEvent(new CustomEvent('nile-change', { detail: { value }, bubbles: false, composed: true }));
|
|
2926
|
+
/** the user's path: choose the unit, then type the duration */
|
|
2927
|
+
const enter = async (el, unit, value) => {
|
|
2928
|
+
fire(unitSelect(el), unit);
|
|
2929
|
+
fire(durationInput(el), String(value));
|
|
2930
|
+
await el.updateComplete;
|
|
2931
|
+
};
|
|
2932
|
+
const watch = (el) => {
|
|
2933
|
+
const seen = [];
|
|
2934
|
+
for (const n of ['nile-change', 'nile-changed'])
|
|
2935
|
+
el.addEventListener(n, (e) => seen.push([n, e.detail]));
|
|
2936
|
+
return seen;
|
|
2937
|
+
};
|
|
2938
|
+
const finite = (d) => d instanceof Date && !isNaN(d.getTime());
|
|
2939
|
+
/** valid = the calculation produced a usable range (works for the stored dates before and after the fix) */
|
|
2940
|
+
const valid = (el, unit, n) => {
|
|
2941
|
+
const p = el.createRelativePeriod(unit, n);
|
|
2942
|
+
return finite(p.startDate) && finite(p.endDate);
|
|
2943
|
+
};
|
|
2944
|
+
const largestValid = (el, unit) => {
|
|
2945
|
+
let lo = 1, hi = 1;
|
|
2946
|
+
while (valid(el, unit, hi)) {
|
|
2947
|
+
lo = hi;
|
|
2948
|
+
hi *= 2;
|
|
2949
|
+
if (hi > 1e13)
|
|
2950
|
+
throw new Error('no upper limit found');
|
|
2951
|
+
}
|
|
2952
|
+
while (hi - lo > 1) {
|
|
2953
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
2954
|
+
if (valid(el, unit, mid))
|
|
2955
|
+
lo = mid;
|
|
2956
|
+
else
|
|
2957
|
+
hi = mid;
|
|
2958
|
+
}
|
|
2959
|
+
return lo;
|
|
2960
|
+
};
|
|
2961
|
+
for (const variant of VARIANTS) {
|
|
2962
|
+
describe(`[${variant}]`, () => {
|
|
2963
|
+
for (const unit of UNITS) {
|
|
2964
|
+
it(`F8 ${unit}: an overflow duration stores no dates, disables Apply and emits nothing`, async () => {
|
|
2965
|
+
const el = await make(variant);
|
|
2966
|
+
if (variant === 'default') {
|
|
2967
|
+
// published default: no overflow guard; the calculation yields an Invalid Date
|
|
2968
|
+
expect(finite(el.createRelativePeriod(unit, OVERFLOW[unit]).startDate), 'published: invalid start').to.be.false;
|
|
2969
|
+
return;
|
|
2970
|
+
}
|
|
2971
|
+
const seen = watch(el);
|
|
2972
|
+
await enter(el, unit, OVERFLOW[unit]);
|
|
2973
|
+
expect(el.startDate, 'startDate').to.be.null;
|
|
2974
|
+
expect(el.endDate, 'endDate').to.be.null;
|
|
2975
|
+
expect(isDisabled(el), 'Apply disabled').to.be.true;
|
|
2976
|
+
applyBtn(el).click();
|
|
2977
|
+
await el.updateComplete;
|
|
2978
|
+
expect(seen, 'no nile-change / nile-changed').to.deep.equal([]);
|
|
2979
|
+
});
|
|
2980
|
+
it(`F8 ${unit}: valid -> overflow clears the previous selection, correcting it restores Apply`, async () => {
|
|
2981
|
+
const el = await make(variant);
|
|
2982
|
+
if (variant === 'default') {
|
|
2983
|
+
await enter(el, unit, 3);
|
|
2984
|
+
expect(finite(el.startDate) && finite(el.endDate), 'valid range stored').to.be.true;
|
|
2985
|
+
expect(isDisabled(el), 'Apply enabled for a valid duration').to.be.false;
|
|
2986
|
+
return;
|
|
2987
|
+
}
|
|
2988
|
+
await enter(el, unit, 3);
|
|
2989
|
+
expect(finite(el.startDate) && finite(el.endDate), 'valid range stored').to.be.true;
|
|
2990
|
+
expect(isDisabled(el), 'Apply enabled for a valid duration').to.be.false;
|
|
2991
|
+
await enter(el, unit, OVERFLOW[unit]);
|
|
2992
|
+
expect(el.startDate, 'previous selection cleared').to.be.null;
|
|
2993
|
+
expect(el.endDate).to.be.null;
|
|
2994
|
+
expect(isDisabled(el)).to.be.true;
|
|
2995
|
+
await enter(el, unit, 3);
|
|
2996
|
+
expect(finite(el.startDate) && finite(el.endDate), 'recovered').to.be.true;
|
|
2997
|
+
expect(isDisabled(el)).to.be.false;
|
|
2998
|
+
const seen = watch(el);
|
|
2999
|
+
applyBtn(el).click();
|
|
3000
|
+
await el.updateComplete;
|
|
3001
|
+
const changes = seen.filter(([n]) => n === 'nile-change');
|
|
3002
|
+
expect(changes.length, 'exactly one nile-change').to.equal(1);
|
|
3003
|
+
const detail = changes[0][1];
|
|
3004
|
+
expect(finite(detail.startDate) && finite(detail.endDate), 'emitted dates are valid').to.be.true;
|
|
3005
|
+
expect(detail.endDate.getTime()).to.be.greaterThan(detail.startDate.getTime());
|
|
3006
|
+
});
|
|
3007
|
+
it(`F8 ${unit}: the largest representable duration is accepted, one more is rejected (no invented limit)`, async () => {
|
|
3008
|
+
const el = await make(variant);
|
|
3009
|
+
if (variant === 'default') {
|
|
3010
|
+
// published default: validity boundary is whatever Date arithmetic yields; no extra guard
|
|
3011
|
+
const max = largestValid(el, unit);
|
|
3012
|
+
expect(max).to.be.greaterThan(1e6);
|
|
3013
|
+
expect(valid(el, unit, max + 1)).to.be.false;
|
|
3014
|
+
return;
|
|
3015
|
+
}
|
|
3016
|
+
const max = largestValid(el, unit);
|
|
3017
|
+
expect(max, 'the representable range is huge, not an arbitrary cap').to.be.greaterThan(1e6);
|
|
3018
|
+
await enter(el, unit, max);
|
|
3019
|
+
expect(finite(el.startDate) && finite(el.endDate), `${max} ${unit} is accepted`).to.be.true;
|
|
3020
|
+
expect(isDisabled(el)).to.be.false;
|
|
3021
|
+
await enter(el, unit, max + 1);
|
|
3022
|
+
expect(el.startDate, `${max + 1} ${unit} is rejected`).to.be.null;
|
|
3023
|
+
expect(isDisabled(el)).to.be.true;
|
|
3024
|
+
});
|
|
3025
|
+
}
|
|
3026
|
+
it('F8 valid durations are unchanged: minutes/hours are exact elapsed time, days/weeks/months are calendar steps', async () => {
|
|
3027
|
+
const el = await make(variant);
|
|
3028
|
+
if (variant === 'default') {
|
|
3029
|
+
// published wall-clock arithmetic (setMinutes/setHours/setMonth)
|
|
3030
|
+
for (const [unit, n] of [['minutes', 45], ['hours', 5], ['months', 1], ['months', 18], ['days', 10]]) {
|
|
3031
|
+
const { startDate } = el.createRelativePeriod(unit, n);
|
|
3032
|
+
const e = new Date();
|
|
3033
|
+
if (unit === 'minutes')
|
|
3034
|
+
e.setMinutes(e.getMinutes() - n);
|
|
3035
|
+
else if (unit === 'hours')
|
|
3036
|
+
e.setHours(e.getHours() - n);
|
|
3037
|
+
else if (unit === 'months')
|
|
3038
|
+
e.setMonth(e.getMonth() - n);
|
|
3039
|
+
else
|
|
3040
|
+
e.setDate(e.getDate() - n);
|
|
3041
|
+
expect(Math.abs(startDate.getTime() - e.getTime()), `${n} ${unit}`).to.be.lessThan(5000);
|
|
3042
|
+
}
|
|
3043
|
+
return;
|
|
3044
|
+
}
|
|
3045
|
+
const MS = { minutes: 60000, hours: 3600000 };
|
|
3046
|
+
for (const unit of ['minutes', 'hours']) {
|
|
3047
|
+
for (const n of [1, 45, 500]) {
|
|
3048
|
+
const { startDate, endDate } = el.createRelativePeriod(unit, n);
|
|
3049
|
+
expect(endDate.getTime() - startDate.getTime(), `${n} ${unit}`).to.equal(n * MS[unit]);
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
for (const [unit, n] of [['days', 1], ['days', 90], ['weeks', 2], ['months', 1], ['months', 18]]) {
|
|
3053
|
+
const { startDate, endDate } = el.createRelativePeriod(unit, n);
|
|
3054
|
+
expect(finite(startDate) && finite(endDate), `${n} ${unit}`).to.be.true;
|
|
3055
|
+
expect(startDate.getTime()).to.be.lessThan(endDate.getTime());
|
|
3056
|
+
}
|
|
3057
|
+
const { startDate } = el.createRelativePeriod('days', 10);
|
|
3058
|
+
const expected = new Date();
|
|
3059
|
+
expected.setDate(expected.getDate() - 10);
|
|
3060
|
+
expect(Math.abs(startDate.getTime() - expected.getTime())).to.be.lessThan(5000);
|
|
3061
|
+
});
|
|
3062
|
+
it('F8 an overflow does not emit nile-clear / nile-type-change and keeps the unit and typed value', async () => {
|
|
3063
|
+
const el = await make(variant);
|
|
3064
|
+
if (variant === 'default')
|
|
3065
|
+
return; // published default has no overflow handling
|
|
3066
|
+
const other = [];
|
|
3067
|
+
for (const n of ['nile-clear', 'nile-type-change'])
|
|
3068
|
+
el.addEventListener(n, () => other.push(n));
|
|
3069
|
+
await enter(el, 'days', 999999999);
|
|
3070
|
+
expect(el.selectedUnit).to.equal('days');
|
|
3071
|
+
expect(el.selectedValue).to.equal(999999999);
|
|
3072
|
+
expect(el.type).to.equal('relative');
|
|
3073
|
+
expect(other).to.deep.equal([]);
|
|
3074
|
+
});
|
|
3075
|
+
});
|
|
3076
|
+
}
|
|
3077
|
+
});
|
|
3078
|
+
describe('NileCalendar year/month dropdowns only navigate', () => {
|
|
3079
|
+
for (const variant of ['default', 'date-time-picker']) {
|
|
3080
|
+
describe(`${variant} variant`, () => {
|
|
3081
|
+
const make = async () => {
|
|
3082
|
+
const el = await fixture(html `<nile-calendar variant=${variant} showMonthDropdown showYearDropdown value=${variant === 'default' ? '2026-10-13T12:00:00' : '2026-10-13'}></nile-calendar>`);
|
|
3083
|
+
await el.updateComplete;
|
|
3084
|
+
return el;
|
|
3085
|
+
};
|
|
3086
|
+
const record = (el) => {
|
|
3087
|
+
const seen = [];
|
|
3088
|
+
for (const name of ['nile-change', 'nile-changed', 'nile-year-change', 'nile-month-change']) {
|
|
3089
|
+
el.addEventListener(name, (e) => seen.push({ name, detail: e.detail }));
|
|
3090
|
+
}
|
|
3091
|
+
return seen;
|
|
3092
|
+
};
|
|
3093
|
+
const pickYear = async (el, y) => {
|
|
3094
|
+
const item = [...el.shadowRoot.querySelectorAll('.year-menu-item')]
|
|
3095
|
+
.find((m) => m.textContent.trim() === String(y));
|
|
3096
|
+
item.click();
|
|
3097
|
+
await el.updateComplete;
|
|
3098
|
+
};
|
|
3099
|
+
const pickMonth = async (el, m0) => {
|
|
3100
|
+
el.shadowRoot.querySelectorAll('.month-menu-item')[m0].click();
|
|
3101
|
+
await el.updateComplete;
|
|
3102
|
+
};
|
|
3103
|
+
const countOf = (seen, name) => seen.filter(s => s.name === name).length;
|
|
3104
|
+
it('picking a year moves the view, keeps the selected date, and emits only nile-year-change', async () => {
|
|
3105
|
+
const el = await make();
|
|
3106
|
+
const seen = record(el);
|
|
3107
|
+
await pickYear(el, 2020);
|
|
3108
|
+
expect(el.currentYear).to.equal(2020);
|
|
3109
|
+
expect(el.value.getFullYear(), 'selected date').to.equal(2026);
|
|
3110
|
+
expect(el.value.getMonth()).to.equal(9);
|
|
3111
|
+
expect(el.value.getDate()).to.equal(13);
|
|
3112
|
+
expect(seen.map(s => s.name)).to.deep.equal(['nile-year-change']);
|
|
3113
|
+
expect(seen[0].detail).to.deep.equal({ year: 2020 });
|
|
3114
|
+
});
|
|
3115
|
+
it('picking a month moves the view, keeps the selected date, and emits only nile-month-change', async () => {
|
|
3116
|
+
const el = await make();
|
|
3117
|
+
const seen = record(el);
|
|
3118
|
+
await pickMonth(el, 2);
|
|
3119
|
+
expect(el.currentMonth).to.equal(2);
|
|
3120
|
+
expect(el.value.getMonth(), 'selected month').to.equal(9);
|
|
3121
|
+
expect(seen.map(s => s.name)).to.deep.equal(['nile-month-change']);
|
|
3122
|
+
});
|
|
3123
|
+
it('picking a year and then a day selects that day in the picked year', async () => {
|
|
3124
|
+
const el = await make();
|
|
3125
|
+
await pickYear(el, 2020);
|
|
3126
|
+
el.shadowRoot.querySelectorAll('.day_date:not(.filler)')[3].click();
|
|
3127
|
+
await el.updateComplete;
|
|
3128
|
+
expect(el.value.getFullYear()).to.equal(2020);
|
|
3129
|
+
expect(el.value.getMonth()).to.equal(9);
|
|
3130
|
+
});
|
|
3131
|
+
});
|
|
3132
|
+
}
|
|
3133
|
+
it('date-time-picker variant: Apply after navigation only emits the original selected date, once', async () => {
|
|
3134
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" showMonthDropdown showYearDropdown value="2026-10-13"></nile-calendar>`);
|
|
3135
|
+
await el.updateComplete;
|
|
3136
|
+
const item = [...el.shadowRoot.querySelectorAll('.year-menu-item')]
|
|
3137
|
+
.find((m) => m.textContent.trim() === '2020');
|
|
3138
|
+
item.click();
|
|
3139
|
+
await el.updateComplete;
|
|
3140
|
+
const changes = [];
|
|
3141
|
+
el.addEventListener('nile-change', (e) => changes.push(e.detail));
|
|
3142
|
+
const btn = el.shadowRoot.querySelector('.apply-button');
|
|
3143
|
+
await btn.updateComplete;
|
|
3144
|
+
btn.click();
|
|
3145
|
+
expect(changes).to.have.length(1);
|
|
3146
|
+
expect(changes[0].value).to.match(/^2026-10-13T/);
|
|
3147
|
+
expect(new Date(changes[0].unixEpoch).getUTCFullYear()).to.equal(2026);
|
|
3148
|
+
});
|
|
3149
|
+
});
|
|
3150
|
+
describe('NileCalendar manual From/To inputs: the default reads a two-digit year as published; only the date-time-picker variant keeps an existing bound\'s century', () => {
|
|
3151
|
+
const make = async (format, range) => {
|
|
3152
|
+
const el = await fixture(html `<nile-calendar variant="default" range showManualInputs dateFormat=${format} value=${range ? JSON.stringify(range) : ''}></nile-calendar>`);
|
|
3153
|
+
await el.updateComplete;
|
|
3154
|
+
await new Promise(r => setTimeout(r, 50));
|
|
3155
|
+
return el;
|
|
3156
|
+
};
|
|
3157
|
+
// the four manual inputs are: start date, start time, end date, end time
|
|
3158
|
+
const START = 0;
|
|
3159
|
+
const END = 2;
|
|
3160
|
+
const fire = async (el, i, v) => {
|
|
3161
|
+
el.shadowRoot.querySelectorAll('nile-input')[i]
|
|
3162
|
+
.dispatchEvent(new CustomEvent('nile-change', { detail: { value: v }, bubbles: true, composed: true }));
|
|
3163
|
+
await el.updateComplete;
|
|
3164
|
+
};
|
|
3165
|
+
const SEEDED = { startDate: '1999-06-10', endDate: '1999-06-20' };
|
|
3166
|
+
it('R1. default calendar, seeded 1999 start: editing its day reads 99 as 2099, exactly as published', async () => {
|
|
3167
|
+
const el = await make('DD/MM/YY', SEEDED);
|
|
3168
|
+
const existing = el.startDate;
|
|
3169
|
+
await fire(el, START, '11/06/99');
|
|
3170
|
+
const exp = published.parse('11/06/99', 'DD/MM/YY', existing);
|
|
3171
|
+
const ymd = (d) => [d.getFullYear(), d.getMonth(), d.getDate()];
|
|
3172
|
+
expect(ymd(el.startDate)).to.deep.equal(ymd(exp));
|
|
3173
|
+
expect(el.startDate.getFullYear(), 'published: no century preservation').to.equal(2099);
|
|
3174
|
+
});
|
|
3175
|
+
it('R2. default calendar, seeded 1999 end: editing its day reads 99 as 2099, exactly as published', async () => {
|
|
3176
|
+
const el = await make('DD/MM/YY', SEEDED);
|
|
3177
|
+
const existing = el.endDate;
|
|
3178
|
+
await fire(el, END, '21/06/99');
|
|
3179
|
+
const exp = published.parse('21/06/99', 'DD/MM/YY', existing);
|
|
3180
|
+
expect([el.endDate.getFullYear(), el.endDate.getMonth(), el.endDate.getDate()]).to.deep.equal([exp.getFullYear(), exp.getMonth(), exp.getDate()]);
|
|
3181
|
+
expect(el.endDate.getFullYear()).to.equal(2099);
|
|
3182
|
+
});
|
|
3183
|
+
it('R8. date-time-picker variant: an existing bound keeps its century while the two digits are unchanged', async () => {
|
|
3184
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" dateFormat="DD/MM/YY"></nile-calendar>`);
|
|
3185
|
+
const existing = new Date(1999, 5, 10);
|
|
3186
|
+
expect(el.parseDate('11/06/99', existing).getFullYear(), 'unchanged digits keep 1999').to.equal(1999);
|
|
3187
|
+
expect(el.parseDate('11/06/98', existing).getFullYear(), 'changed digits read as 20xx').to.equal(2098);
|
|
3188
|
+
expect(el.parseDate('11/06/99', null).getFullYear(), 'no existing bound reads as 20xx').to.equal(2099);
|
|
3189
|
+
});
|
|
3190
|
+
it('R3. changed digits follow the 2000-2099 reading (start 98 → 2098, end 05 → 2005)', async () => {
|
|
3191
|
+
const el = await make('DD/MM/YY', SEEDED);
|
|
3192
|
+
await fire(el, START, '10/06/98');
|
|
3193
|
+
expect(el.startDate.getFullYear()).to.equal(2098);
|
|
3194
|
+
await fire(el, END, '20/06/05');
|
|
3195
|
+
expect(el.endDate.getFullYear()).to.equal(2005);
|
|
3196
|
+
});
|
|
3197
|
+
it('R4. empty input: a new 99 is 2099 and 00 is 2000, as before', async () => {
|
|
3198
|
+
const el = await make('DD/MM/YY');
|
|
3199
|
+
await fire(el, START, '10/06/99');
|
|
3200
|
+
expect(el.startDate.getFullYear()).to.equal(2099);
|
|
3201
|
+
await fire(el, END, '20/06/00');
|
|
3202
|
+
expect(el.endDate.getFullYear()).to.equal(2000);
|
|
3203
|
+
});
|
|
3204
|
+
it('R5. a 2026 bound still edits to 2026 and moves to 2027', async () => {
|
|
3205
|
+
const el = await make('DD/MM/YY', { startDate: '2026-06-10', endDate: '2026-06-20' });
|
|
3206
|
+
await fire(el, START, '11/06/26');
|
|
3207
|
+
expect(el.startDate.getFullYear()).to.equal(2026);
|
|
3208
|
+
await fire(el, START, '11/06/27');
|
|
3209
|
+
expect(el.startDate.getFullYear()).to.equal(2027);
|
|
3210
|
+
});
|
|
3211
|
+
it('R6. YYYY is unaffected: 1999 stays 1999 and a typed 2099 is 2099', async () => {
|
|
3212
|
+
const el = await make('DD/MM/YYYY', SEEDED);
|
|
3213
|
+
await fire(el, START, '11/06/1999');
|
|
3214
|
+
expect(el.startDate.getFullYear()).to.equal(1999);
|
|
3215
|
+
await fire(el, START, '11/06/2099');
|
|
3216
|
+
expect(el.startDate.getFullYear()).to.equal(2099);
|
|
3217
|
+
});
|
|
3218
|
+
it('R7. the preserved century leaves the time and the other bound alone', async () => {
|
|
3219
|
+
const el = await make('DD/MM/YY', SEEDED);
|
|
3220
|
+
const endBefore = el.endDate.getTime();
|
|
3221
|
+
await fire(el, START, '12/06/99');
|
|
3222
|
+
expect(el.endDate.getTime()).to.equal(endBefore);
|
|
3223
|
+
expect(el.startDate.getHours()).to.equal(0);
|
|
3224
|
+
expect(el.startDate.getMinutes()).to.equal(0);
|
|
3225
|
+
});
|
|
3226
|
+
});
|
|
3227
|
+
describe('NileCalendar offset-less values: a clock face that is not a time of day is unparseable, never rolled into the next day', () => {
|
|
3228
|
+
const VALID = [
|
|
3229
|
+
['2026-03-01T10:30', [2026, 3, 1, 10, 30, 0, 0]],
|
|
3230
|
+
['2026-03-01T00:00:00', [2026, 3, 1, 0, 0, 0, 0]],
|
|
3231
|
+
['2026-03-01T23:59:59.999', [2026, 3, 1, 23, 59, 59, 999]],
|
|
3232
|
+
['2026-02-31T10:00:00', [2026, 2, 28, 10, 0, 0, 0]], // an impossible day still clamps to the month end
|
|
3233
|
+
];
|
|
3234
|
+
for (const [raw, [y, mo, d, h, mi, s, ms]] of VALID) {
|
|
3235
|
+
it(`T1. ${raw} is read as written`, () => {
|
|
3236
|
+
expect(wallClockFromOffsetLess(raw)).to.deep.equal({ year: y, month: mo, day: d, hours: h, minutes: mi, seconds: s, ms });
|
|
3237
|
+
});
|
|
3238
|
+
}
|
|
3239
|
+
for (const raw of [
|
|
3240
|
+
'2026-03-01T25:61:00', '2026-03-01T25:00:00', '2026-03-01T10:61:00', '2026-03-01T10:30:61',
|
|
3241
|
+
'2026-03-01T99:00', '2026-03-01T24:00:01', '2026-03-01T24:01:00', '2026-03-01T24:00:00.001',
|
|
3242
|
+
]) {
|
|
3243
|
+
it(`T2. ${raw} is rejected (null), not rolled over`, () => {
|
|
3244
|
+
expect(wallClockFromOffsetLess(raw)).to.be.null;
|
|
3245
|
+
});
|
|
3246
|
+
}
|
|
3247
|
+
it('T3. 24:00:00 is ISO end-of-day: it is kept and resolves to the next midnight', () => {
|
|
3248
|
+
expect(wallClockFromOffsetLess('2026-03-01T24:00:00')).to.deep.equal({ year: 2026, month: 3, day: 2, hours: 0, minutes: 0, seconds: 0, ms: 0 });
|
|
3249
|
+
expect(wallClockFromOffsetLess('2026-03-01T24:00:00.000')).to.deep.equal({ year: 2026, month: 3, day: 2, hours: 0, minutes: 0, seconds: 0, ms: 0 });
|
|
3250
|
+
});
|
|
3251
|
+
it('T4. isClockTime: the shared rule, edge by edge', () => {
|
|
3252
|
+
for (const [h, m, s, ms] of [[0, 0, 0, 0], [23, 59, 59, 999], [24, 0, 0, 0], [12, 0, 0, 0]])
|
|
3253
|
+
expect(isClockTime(h, m, s, ms), `${h}:${m}:${s}.${ms}`).to.be.true;
|
|
3254
|
+
for (const [h, m, s, ms] of [[24, 0, 0, 1], [24, 0, 1, 0], [24, 1, 0, 0], [25, 0, 0, 0], [0, 60, 0, 0], [0, 0, 60, 0], [0, 0, 0, 1000], [-1, 0, 0, 0], [NaN, 0, 0, 0]])
|
|
3255
|
+
expect(isClockTime(h, m, s, ms), `${h}:${m}:${s}.${ms}`).to.be.false;
|
|
3256
|
+
});
|
|
3257
|
+
for (const variant of ['default', 'date-time-picker']) {
|
|
3258
|
+
it(`T5. [${variant}] the element shows no selection for 2026-03-01T25:61:00, and a valid sibling still selects`, async () => {
|
|
3259
|
+
const bad = await fixture(html `<nile-calendar variant=${variant} value="2026-03-01T25:61:00"></nile-calendar>`);
|
|
3260
|
+
await bad.updateComplete;
|
|
3261
|
+
expect(bad.value ?? null, 'no selection from an impossible clock face').to.be.null;
|
|
3262
|
+
const good = await fixture(html `<nile-calendar variant=${variant} value="2026-03-01T10:30:00"></nile-calendar>`);
|
|
3263
|
+
await good.updateComplete;
|
|
3264
|
+
expect([good.value.getFullYear(), good.value.getMonth() + 1, good.value.getDate()]).to.deep.equal([2026, 3, 1]);
|
|
3265
|
+
});
|
|
3266
|
+
}
|
|
3267
|
+
});
|
|
3268
|
+
describe('NileCalendar bracketed values: ISO end-of-day 24:00:00.000 is the next day 00:00:00', () => {
|
|
3269
|
+
const NEXT_MIDNIGHT_UTC = Date.UTC(2026, 2, 2);
|
|
3270
|
+
it('B1. 24:00:00(.000) in a bracketed value is the next day 00:00:00 in that zone', () => {
|
|
3271
|
+
expect(instantFromBracketedValue('2026-03-01T24:00:00.000[GMT+00:00 UTC]')).to.deep.equal({ epochMs: NEXT_MIDNIGHT_UTC, timezone: 'UTC' });
|
|
3272
|
+
expect(instantFromBracketedValue('2026-03-01T24:00:00[GMT+00:00 UTC]')).to.deep.equal({ epochMs: NEXT_MIDNIGHT_UTC, timezone: 'UTC' });
|
|
3273
|
+
expect(instantFromBracketedValue('2026-03-01T24:00:00.000[GMT+05:30 Asia/Kolkata]'))
|
|
3274
|
+
.to.deep.equal({ epochMs: NEXT_MIDNIGHT_UTC - 330 * 60000, timezone: 'Asia/Kolkata' });
|
|
3275
|
+
expect(instantFromBracketedValue('2026-12-31T24:00:00.000[GMT+00:00 UTC]').epochMs, 'year rollover').to.equal(Date.UTC(2027, 0, 1));
|
|
3276
|
+
expect(instantFromBracketedValue('2028-02-28T24:00:00.000[GMT+00:00 UTC]').epochMs, 'leap day').to.equal(Date.UTC(2028, 1, 29));
|
|
3277
|
+
});
|
|
3278
|
+
it('B2. anything past 24:00:00.000, or hours above 24, is not a time and stays unparseable', () => {
|
|
3279
|
+
for (const bad of [
|
|
3280
|
+
'2026-03-01T24:00:01.000[GMT+00:00 UTC]', '2026-03-01T24:01:00.000[GMT+00:00 UTC]', '2026-03-01T24:00:00.001[GMT+00:00 UTC]',
|
|
3281
|
+
'2026-03-01T25:00:00.000[GMT+00:00 UTC]', '2026-03-01T99:00:00.000[GMT+00:00 UTC]', '2026-03-01T24:00:01[GMT+00:00 UTC]',
|
|
3282
|
+
])
|
|
3283
|
+
expect(instantFromBracketedValue(bad), bad).to.be.null;
|
|
3284
|
+
});
|
|
3285
|
+
it('B3. offset and zone validation is unchanged: a wrong offset for the zone, an invalid zone, and ordinary hours', () => {
|
|
3286
|
+
expect(instantFromBracketedValue('2026-03-01T24:00:00.000[GMT+09:00 Asia/Kolkata]'), 'offset does not match the zone').to.be.null;
|
|
3287
|
+
expect(instantFromBracketedValue('2026-03-01T24:00:00.000[GMT+00:00 Mars/Phobos]'), 'unknown zone').to.be.null;
|
|
3288
|
+
expect(instantFromBracketedValue('2026-03-01T23:59:59.999[GMT+00:00 UTC]').epochMs).to.equal(NEXT_MIDNIGHT_UTC - 1);
|
|
3289
|
+
expect(instantFromBracketedValue('2026-03-01T00:00:00.000[GMT+00:00 UTC]').epochMs).to.equal(Date.UTC(2026, 2, 1));
|
|
3290
|
+
});
|
|
3291
|
+
for (const [value, zoneName, offset, epoch] of [
|
|
3292
|
+
['2026-03-01T24:00:00.000[GMT+00:00 UTC]', 'UTC', '+00:00', NEXT_MIDNIGHT_UTC],
|
|
3293
|
+
['2026-03-01T24:00:00[GMT+05:30 Asia/Kolkata]', 'Asia/Kolkata', '+05:30', NEXT_MIDNIGHT_UTC - 330 * 60000],
|
|
3294
|
+
]) {
|
|
3295
|
+
it(`B4. the panel selects the next day for ${value}, and Apply emits it`, async () => {
|
|
3296
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" value=${value}></nile-calendar>`);
|
|
3297
|
+
await el.updateComplete;
|
|
3298
|
+
expect([el.value.getFullYear(), el.value.getMonth() + 1, el.value.getDate()], 'selected day').to.deep.equal([2026, 3, 2]);
|
|
3299
|
+
expect(el.shadowRoot.querySelector('.selected-date').textContent.trim(), 'highlighted cell').to.equal('2');
|
|
3300
|
+
const apply = el.shadowRoot.querySelector('.apply-button');
|
|
3301
|
+
expect(apply.hasAttribute('disabled'), 'Apply enabled').to.be.false;
|
|
3302
|
+
let detail;
|
|
3303
|
+
el.addEventListener('nile-change', (e) => { detail = e.detail; });
|
|
3304
|
+
apply.click();
|
|
3305
|
+
expect(detail.value).to.include('2026-03-02T00:00:00');
|
|
3306
|
+
expect(detail.value).to.include(`[GMT${offset} ${zoneName}]`);
|
|
3307
|
+
expect(detail.timezone).to.equal(zoneName);
|
|
3308
|
+
expect(detail.localTimezoneValue.getTime()).to.equal(epoch);
|
|
3309
|
+
});
|
|
3310
|
+
}
|
|
3311
|
+
it('B5. a rejected bracketed 24:00 value still leaves no selection and Apply disabled', async () => {
|
|
3312
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" value="2026-03-01T24:00:01.000[GMT+00:00 UTC]"></nile-calendar>`);
|
|
3313
|
+
await el.updateComplete;
|
|
3314
|
+
expect(el.value ?? null).to.be.null;
|
|
3315
|
+
expect(el.shadowRoot.querySelector('.apply-button').hasAttribute('disabled')).to.be.true;
|
|
3316
|
+
});
|
|
3317
|
+
});
|
|
3318
|
+
describe('NileCalendar default variant: published DOM, defaults and inert additive properties', () => {
|
|
3319
|
+
const dom = (el) => el.shadowRoot.innerHTML
|
|
3320
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
3321
|
+
.replace(/\b(id|aria-controls|aria-labelledby|for)="([a-z-]+)-\d+"/g, '$1="$2-#"');
|
|
3322
|
+
const build = async (tpl) => {
|
|
3323
|
+
const el = (await fixture(tpl));
|
|
3324
|
+
await el.updateComplete;
|
|
3325
|
+
await new Promise(r => setTimeout(r, 20));
|
|
3326
|
+
return el;
|
|
3327
|
+
};
|
|
3328
|
+
it('PB1. public property defaults are the published ones', async () => {
|
|
3329
|
+
const el = await build(html `<nile-calendar></nile-calendar>`);
|
|
3330
|
+
expect(el.range).to.be.false;
|
|
3331
|
+
expect(el.type).to.equal('absolute');
|
|
3332
|
+
expect(el.dateFormat).to.equal('MM/DD/YYYY');
|
|
3333
|
+
expect(el.hideTypes).to.be.false;
|
|
3334
|
+
expect(el.showManualInputs).to.be.false;
|
|
3335
|
+
expect(el.allowClear).to.be.false;
|
|
3336
|
+
expect(el.doubleClickUnselect).to.be.false;
|
|
3337
|
+
expect(el.disabled).to.be.false;
|
|
3338
|
+
expect(el.valueAttribute).to.be.null;
|
|
3339
|
+
expect(el.allowedDates).to.equal('{}');
|
|
3340
|
+
expect(el.startYear).to.be.undefined;
|
|
3341
|
+
expect(el.endYear).to.be.undefined;
|
|
3342
|
+
expect(el.structuredInput).to.be.false;
|
|
3343
|
+
expect(el.variant).to.equal('default');
|
|
3344
|
+
});
|
|
3345
|
+
it('PB2. the default renders none of the date-time-picker markup and no day-cell ARIA', async () => {
|
|
3346
|
+
for (const tpl of [html `<nile-calendar></nile-calendar>`, html `<nile-calendar variant="default"></nile-calendar>`]) {
|
|
3347
|
+
const el = await build(tpl);
|
|
3348
|
+
const root = el.shadowRoot;
|
|
3349
|
+
expect(root.querySelector('.base--date-time-picker')).to.be.null;
|
|
3350
|
+
expect(root.querySelector('[class*="tp-"]')).to.be.null;
|
|
3351
|
+
expect(root.querySelector('.base')).to.not.be.null;
|
|
3352
|
+
root.querySelectorAll('[part^="calendar-day"]').forEach((c) => {
|
|
3353
|
+
for (const a of ['role', 'aria-label', 'aria-pressed', 'aria-current', 'aria-disabled', 'aria-hidden'])
|
|
3354
|
+
expect(c.hasAttribute(a), `default day cell has no ${a}`).to.be.false;
|
|
3355
|
+
});
|
|
3356
|
+
}
|
|
3357
|
+
});
|
|
3358
|
+
it('PB3. showTypes / timezone / hideTimeFields / structuredInput do not change the default DOM, state or events', async () => {
|
|
3359
|
+
const plain = await build(html `<nile-calendar value="2026-06-15" range showManualInputs></nile-calendar>`);
|
|
3360
|
+
const extra = await build(html `<nile-calendar value="2026-06-15" range showManualInputs showTypes timezone="Asia/Kolkata"
|
|
3361
|
+
.hideTimeFields=${['seconds', 'timezone']}></nile-calendar>`);
|
|
3362
|
+
expect(dom(extra)).to.equal(dom(plain));
|
|
3363
|
+
expect(extra.value?.getTime()).to.equal(plain.value?.getTime());
|
|
3364
|
+
expect([extra.currentMonth, extra.currentYear]).to.deep.equal([plain.currentMonth, plain.currentYear]);
|
|
3365
|
+
});
|
|
3366
|
+
it('PB4. clearing the value attribute leaves the relative unit/value untouched, as published', async () => {
|
|
3367
|
+
const el = await build(html `<nile-calendar type="relative" range value='{"startDate":"2026-06-10","endDate":"2026-06-20"}'></nile-calendar>`);
|
|
3368
|
+
el.handleTimeValueClick('days', 7, new CustomEvent('x'));
|
|
3369
|
+
el.valueAttribute = '';
|
|
3370
|
+
await el.updateComplete;
|
|
3371
|
+
expect(el.selectedUnit).to.equal('days');
|
|
3372
|
+
expect(el.selectedValue).to.equal(7);
|
|
3373
|
+
expect(el.tpHours).to.be.null;
|
|
3374
|
+
});
|
|
3375
|
+
it('PB5. the default relative branch is the published one: wall-clock arithmetic, no overflow guard', async () => {
|
|
3376
|
+
const el = await build(html `<nile-calendar type="relative" range></nile-calendar>`);
|
|
3377
|
+
const r = el.createRelativePeriod('months', 1e12);
|
|
3378
|
+
expect(isNaN(el.startDate.getTime()), 'an overflowing duration stores an Invalid Date, as published').to.be.true;
|
|
3379
|
+
expect(r).to.not.be.null;
|
|
3380
|
+
const m = el.createRelativePeriod('minutes', 90);
|
|
3381
|
+
const expected = new Date();
|
|
3382
|
+
expected.setMinutes(expected.getMinutes() - 90);
|
|
3383
|
+
expect(Math.abs(el.startDate.getTime() - expected.getTime())).to.be.lessThan(2000);
|
|
3384
|
+
expect(m).to.not.be.undefined;
|
|
3385
|
+
});
|
|
3386
|
+
});
|
|
3387
|
+
describe('NileCalendar day-cell Space/Enter keyboard handling', () => {
|
|
3388
|
+
const dayCell = (el, n) => [...el.shadowRoot.querySelectorAll('.day_date:not(.filler)')].find(c => c.textContent.trim() === String(n));
|
|
3389
|
+
// dispatchEvent returns false when a listener called preventDefault, i.e. the browser would not scroll on Space
|
|
3390
|
+
const press = (target, key) => target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, composed: true }));
|
|
3391
|
+
const variant = async () => {
|
|
3392
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" value="2026-10-15"></nile-calendar>`);
|
|
3393
|
+
el.expanded = true;
|
|
3394
|
+
await el.updateComplete;
|
|
3395
|
+
return el;
|
|
3396
|
+
};
|
|
3397
|
+
const pressed = (el) => [...el.shadowRoot.querySelectorAll('.day_date[aria-pressed="true"]')].map(c => c.textContent.trim());
|
|
3398
|
+
it('SP1. variant: Space selects the day and is default-prevented (no page scroll)', async () => {
|
|
3399
|
+
const el = await variant();
|
|
3400
|
+
const cell = dayCell(el, 20);
|
|
3401
|
+
expect(press(cell, ' '), 'Space is default-prevented').to.be.false;
|
|
3402
|
+
await el.updateComplete;
|
|
3403
|
+
expect(pressed(el)).to.deep.equal(['20']);
|
|
3404
|
+
});
|
|
3405
|
+
it('SP2. variant: Enter still selects the day', async () => {
|
|
3406
|
+
const el = await variant();
|
|
3407
|
+
press(dayCell(el, 21), 'Enter');
|
|
3408
|
+
await el.updateComplete;
|
|
3409
|
+
expect(pressed(el)).to.deep.equal(['21']);
|
|
3410
|
+
});
|
|
3411
|
+
it('SP3. variant: Space/Enter on a disabled (not allowed) day select nothing', async () => {
|
|
3412
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" value="2026-10-15" allowedDates='{"startDate":"2026-10-10","endDate":"2026-10-20"}'></nile-calendar>`);
|
|
3413
|
+
el.expanded = true;
|
|
3414
|
+
await el.updateComplete;
|
|
3415
|
+
const cell = dayCell(el, 25);
|
|
3416
|
+
expect(cell.getAttribute('aria-disabled')).to.equal('true');
|
|
3417
|
+
press(cell, ' ');
|
|
3418
|
+
press(cell, 'Enter');
|
|
3419
|
+
await el.updateComplete;
|
|
3420
|
+
expect(pressed(el)).to.deep.equal(['15']);
|
|
3421
|
+
});
|
|
3422
|
+
it('SP4. variant: Tab, letters and Escape are not default-prevented and select nothing; arrow keys now move focus', async () => {
|
|
3423
|
+
const el = await variant();
|
|
3424
|
+
for (const key of ['Tab', 'a', 'Escape']) {
|
|
3425
|
+
expect(press(dayCell(el, 22), key), key).to.be.true;
|
|
3426
|
+
}
|
|
3427
|
+
// day navigation (ArrowLeft/Right/Up/Down, Home, End, PageUp/PageDown) is handled and prevented so the page does not scroll
|
|
3428
|
+
expect(press(dayCell(el, 22), 'ArrowRight'), 'ArrowRight is handled').to.be.false;
|
|
3429
|
+
await el.updateComplete;
|
|
3430
|
+
expect(pressed(el), 'moving focus selects nothing').to.deep.equal(['15']);
|
|
3431
|
+
});
|
|
3432
|
+
it('SP5. default calendar: published handler unchanged — Space still selects and is NOT default-prevented', async () => {
|
|
3433
|
+
const el = await fixture(html `<nile-calendar value="2026-10-15"></nile-calendar>`);
|
|
3434
|
+
await el.updateComplete;
|
|
3435
|
+
const cell = dayCell(el, 20);
|
|
3436
|
+
expect(press(cell, ' '), 'published behaviour: not prevented').to.be.true;
|
|
3437
|
+
await el.updateComplete;
|
|
3438
|
+
expect([...el.shadowRoot.querySelectorAll('.day_date.selected-date')].map(c => c.textContent.trim())).to.deep.equal(['20']);
|
|
3439
|
+
});
|
|
3440
|
+
});
|
|
3441
|
+
describe('YR. startYear / endYear bound month navigation: explicit bounds win, the variant falls back to 2000-2050, the default calendar stays open', () => {
|
|
3442
|
+
const variants = ['default', 'date-time-picker'];
|
|
3443
|
+
const arrows = (el) => Array.from(el.shadowRoot.querySelectorAll('.calendar-header__month-navigation'));
|
|
3444
|
+
const prevArrow = (el) => arrows(el)[0];
|
|
3445
|
+
const nextArrow = (el) => arrows(el)[arrows(el).length - 1];
|
|
3446
|
+
const make = async (variant, bounds, year, month) => {
|
|
3447
|
+
const el = await fixture(html `<nile-calendar variant=${variant} timezone="UTC"
|
|
3448
|
+
.startYear=${bounds.start} .endYear=${bounds.end}></nile-calendar>`);
|
|
3449
|
+
el.currentYear = year;
|
|
3450
|
+
el.currentMonth = month;
|
|
3451
|
+
await el.updateComplete;
|
|
3452
|
+
return el;
|
|
3453
|
+
};
|
|
3454
|
+
const at = (el) => [el.currentYear, el.currentMonth];
|
|
3455
|
+
const step = async (el, arrow) => {
|
|
3456
|
+
arrow.click();
|
|
3457
|
+
await el.updateComplete;
|
|
3458
|
+
};
|
|
3459
|
+
/** 'open' = the default calendar as published (an unset bound is unlimited); 'dtp' = the variant (2000-2050 fallback) */
|
|
3460
|
+
const openOrFallback = (variant) => (variant === 'default' ? 'open' : 'dtp');
|
|
3461
|
+
for (const variant of variants) {
|
|
3462
|
+
it(`YR1 [${variant}]. no bounds: ${variant === 'default' ? 'the arrows stay open past 2000 and 2050 (as published)' : 'the arrows stop at Jan 2000 and Dec 2050'}`, async () => {
|
|
3463
|
+
const lo = await make(variant, {}, 2000, 0);
|
|
3464
|
+
await step(lo, prevArrow(lo));
|
|
3465
|
+
expect(at(lo)).to.deep.equal(openOrFallback(variant) === 'open' ? [1999, 11] : [2000, 0]);
|
|
3466
|
+
expect(prevArrow(lo).getAttribute('tabindex')).to.equal(openOrFallback(variant) === 'open' ? '0' : '-1');
|
|
3467
|
+
const hi = await make(variant, {}, 2050, 11);
|
|
3468
|
+
await step(hi, nextArrow(hi));
|
|
3469
|
+
expect(at(hi)).to.deep.equal(openOrFallback(variant) === 'open' ? [2051, 0] : [2050, 11]);
|
|
3470
|
+
expect(nextArrow(hi).getAttribute('tabindex')).to.equal(openOrFallback(variant) === 'open' ? '0' : '-1');
|
|
3471
|
+
});
|
|
3472
|
+
it(`YR2 [${variant}]. no bounds: inside 2000-2050 both arrows work, including December to January`, async () => {
|
|
3473
|
+
const el = await make(variant, {}, 2026, 11);
|
|
3474
|
+
await step(el, nextArrow(el));
|
|
3475
|
+
expect(at(el)).to.deep.equal([2027, 0]);
|
|
3476
|
+
await step(el, prevArrow(el));
|
|
3477
|
+
expect(at(el)).to.deep.equal([2026, 11]);
|
|
3478
|
+
});
|
|
3479
|
+
it(`YR3 [${variant}]. both bounds: the arrows stop at the first and last month`, async () => {
|
|
3480
|
+
const lo = await make(variant, { start: 2020, end: 2030 }, 2020, 0);
|
|
3481
|
+
expect(prevArrow(lo).getAttribute('tabindex')).to.equal('-1');
|
|
3482
|
+
await step(lo, prevArrow(lo));
|
|
3483
|
+
expect(at(lo)).to.deep.equal([2020, 0]);
|
|
3484
|
+
const hi = await make(variant, { start: 2020, end: 2030 }, 2030, 11);
|
|
3485
|
+
expect(nextArrow(hi).getAttribute('tabindex')).to.equal('-1');
|
|
3486
|
+
await step(hi, nextArrow(hi));
|
|
3487
|
+
expect(at(hi)).to.deep.equal([2030, 11]);
|
|
3488
|
+
await step(hi, prevArrow(hi));
|
|
3489
|
+
expect(at(hi)).to.deep.equal([2030, 10]);
|
|
3490
|
+
});
|
|
3491
|
+
it(`YR4 [${variant}]. explicit bounds outside 2000-2050 take precedence over the fallback`, async () => {
|
|
3492
|
+
const lo = await make(variant, { start: 1990, end: 2060 }, 2000, 0);
|
|
3493
|
+
await step(lo, prevArrow(lo));
|
|
3494
|
+
expect(at(lo), 'passes 2000 down to the explicit start').to.deep.equal([1999, 11]);
|
|
3495
|
+
const hi = await make(variant, { start: 1990, end: 2060 }, 2050, 11);
|
|
3496
|
+
await step(hi, nextArrow(hi));
|
|
3497
|
+
expect(at(hi), 'passes 2050 up to the explicit end').to.deep.equal([2051, 0]);
|
|
3498
|
+
const start = await make(variant, { start: 1990, end: 2060 }, 1990, 0);
|
|
3499
|
+
await step(start, prevArrow(start));
|
|
3500
|
+
expect(at(start)).to.deep.equal([1990, 0]);
|
|
3501
|
+
const end = await make(variant, { start: 1990, end: 2060 }, 2060, 11);
|
|
3502
|
+
await step(end, nextArrow(end));
|
|
3503
|
+
expect(at(end)).to.deep.equal([2060, 11]);
|
|
3504
|
+
});
|
|
3505
|
+
it(`YR5 [${variant}]. only startYear: it bounds backwards; forwards is ${variant === 'default' ? 'open' : 'capped at the 2050 fallback'}`, async () => {
|
|
3506
|
+
const lo = await make(variant, { start: 2020 }, 2020, 0);
|
|
3507
|
+
await step(lo, prevArrow(lo));
|
|
3508
|
+
expect(at(lo)).to.deep.equal([2020, 0]);
|
|
3509
|
+
const hi = await make(variant, { start: 2020 }, 2050, 11);
|
|
3510
|
+
await step(hi, nextArrow(hi));
|
|
3511
|
+
expect(at(hi)).to.deep.equal(variant === 'default' ? [2051, 0] : [2050, 11]);
|
|
3512
|
+
});
|
|
3513
|
+
it(`YR6 [${variant}]. only endYear: it bounds forwards; backwards is ${variant === 'default' ? 'open' : 'capped at the 2000 fallback'}`, async () => {
|
|
3514
|
+
const hi = await make(variant, { end: 2030 }, 2030, 11);
|
|
3515
|
+
await step(hi, nextArrow(hi));
|
|
3516
|
+
expect(at(hi)).to.deep.equal([2030, 11]);
|
|
3517
|
+
const lo = await make(variant, { end: 2030 }, 2000, 0);
|
|
3518
|
+
await step(lo, prevArrow(lo));
|
|
3519
|
+
expect(at(lo)).to.deep.equal(variant === 'default' ? [1999, 11] : [2000, 0]);
|
|
3520
|
+
});
|
|
3521
|
+
it(`YR7 [${variant}]. a bound set after the first render applies from the next click`, async () => {
|
|
3522
|
+
const el = await make(variant, {}, 2030, 11);
|
|
3523
|
+
el.endYear = 2030;
|
|
3524
|
+
await el.updateComplete;
|
|
3525
|
+
await step(el, nextArrow(el));
|
|
3526
|
+
expect(at(el)).to.deep.equal([2030, 11]);
|
|
3527
|
+
el.endYear = undefined;
|
|
3528
|
+
await el.updateComplete;
|
|
3529
|
+
await step(el, nextArrow(el));
|
|
3530
|
+
expect(at(el), '2030 is inside the fallback and the open default').to.deep.equal([2031, 0]);
|
|
3531
|
+
});
|
|
3532
|
+
}
|
|
3533
|
+
it('YR8. the variant without bounds: the year dropdown list and the arrow limits are the same 2000-2050', async () => {
|
|
3534
|
+
const el = await make('date-time-picker', {}, 2000, 0);
|
|
3535
|
+
const options = el.yearOptions;
|
|
3536
|
+
expect([options[0], options.at(-1), options.length]).to.deep.equal([2000, 2050, 51]);
|
|
3537
|
+
await step(el, prevArrow(el));
|
|
3538
|
+
expect(el.currentYear).to.equal(options[0]);
|
|
3539
|
+
});
|
|
3540
|
+
it('YR9. the year dropdown fallback (2000-2050) is unchanged in both variants', async () => {
|
|
3541
|
+
for (const variant of variants) {
|
|
3542
|
+
const el = await fixture(html `<nile-calendar variant=${variant} timezone="UTC" showYearDropdown></nile-calendar>`);
|
|
3543
|
+
expect(el.yearOptions[0], variant).to.equal(2000);
|
|
3544
|
+
expect(el.yearOptions.at(-1), variant).to.equal(2050);
|
|
3545
|
+
expect(el.yearOptions.length, variant).to.equal(51);
|
|
3546
|
+
}
|
|
3547
|
+
});
|
|
3548
|
+
it('YR10. a bound replaces only its own side of the dropdown fallback', async () => {
|
|
3549
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" timezone="UTC" .startYear=${1990}></nile-calendar>`);
|
|
3550
|
+
expect(el.yearOptions[0]).to.equal(1990);
|
|
3551
|
+
expect(el.yearOptions.at(-1)).to.equal(2050);
|
|
3552
|
+
});
|
|
3553
|
+
it('YR11. date-time-picker, explicit startYear 0: year 0001 steps back to 0000 without the 1900 remap, then stops', async () => {
|
|
3554
|
+
const el = await make('date-time-picker', { start: 0, end: 9999 }, 1, 0);
|
|
3555
|
+
await step(el, prevArrow(el));
|
|
3556
|
+
expect(at(el)).to.deep.equal([0, 11]);
|
|
3557
|
+
el.currentMonth = 0;
|
|
3558
|
+
await el.updateComplete;
|
|
3559
|
+
await step(el, prevArrow(el));
|
|
3560
|
+
expect(at(el)).to.deep.equal([0, 0]);
|
|
3561
|
+
});
|
|
3562
|
+
it('YR12. the default calendar is never given the fallback: no bounds means open on both sides far from 2000-2050', async () => {
|
|
3563
|
+
const lo = await make('default', {}, 1850, 0);
|
|
3564
|
+
await step(lo, prevArrow(lo));
|
|
3565
|
+
expect(at(lo)).to.deep.equal([1849, 11]);
|
|
3566
|
+
const hi = await make('default', {}, 2100, 11);
|
|
3567
|
+
await step(hi, nextArrow(hi));
|
|
3568
|
+
expect(at(hi)).to.deep.equal([2101, 0]);
|
|
3569
|
+
});
|
|
3570
|
+
});
|
|
3571
|
+
describe('CS. variant day-label part and the height-lock class (no inline presentation in the variant template)', () => {
|
|
3572
|
+
const baseOf = (el) => el.shadowRoot.querySelector('.base--date-time-picker');
|
|
3573
|
+
const settle = async (el) => {
|
|
3574
|
+
await el.updateComplete;
|
|
3575
|
+
await new Promise(r => requestAnimationFrame(() => r(null)));
|
|
3576
|
+
await el.updateComplete;
|
|
3577
|
+
};
|
|
3578
|
+
const make = async (extra = '') => {
|
|
3579
|
+
const el = await fixture(html `<nile-calendar variant="date-time-picker" timezone="UTC"></nile-calendar>`);
|
|
3580
|
+
await settle(el);
|
|
3581
|
+
return el;
|
|
3582
|
+
};
|
|
3583
|
+
const expand = async (el) => {
|
|
3584
|
+
el.expanded = true;
|
|
3585
|
+
await settle(el);
|
|
3586
|
+
};
|
|
3587
|
+
it('CS1. every current-month day cell has one span.day-label exposed as part="calendar-day-label", and no inline style', async () => {
|
|
3588
|
+
const el = await make();
|
|
3589
|
+
await expand(el);
|
|
3590
|
+
const cells = Array.from(el.shadowRoot.querySelectorAll('.day_date[role="button"]'));
|
|
3591
|
+
expect(cells.length).to.be.greaterThan(27);
|
|
3592
|
+
for (const c of cells) {
|
|
3593
|
+
const labels = c.querySelectorAll('span.day-label');
|
|
3594
|
+
expect(labels.length, c.textContent.trim()).to.equal(1);
|
|
3595
|
+
expect(labels[0].getAttribute('part')).to.equal('calendar-day-label');
|
|
3596
|
+
expect(labels[0].hasAttribute('style'), 'no inline style').to.be.false;
|
|
3597
|
+
expect(getComputedStyle(labels[0]).position).to.equal('relative');
|
|
3598
|
+
}
|
|
3599
|
+
});
|
|
3600
|
+
it('CS2. the day number is still the label text, and the today dot sits inside the positioned label', async () => {
|
|
3601
|
+
const el = await make();
|
|
3602
|
+
el.currentMonth = new Date().getMonth();
|
|
3603
|
+
el.currentYear = new Date().getFullYear();
|
|
3604
|
+
await settle(el);
|
|
3605
|
+
const dot = el.shadowRoot.querySelector('.current__date__dot');
|
|
3606
|
+
if (dot) {
|
|
3607
|
+
const label = dot.closest('.day-label');
|
|
3608
|
+
expect(label, 'dot is inside a day-label').to.exist;
|
|
3609
|
+
expect(dot.offsetParent, 'the label is the dot\'s positioning parent').to.equal(label);
|
|
3610
|
+
}
|
|
3611
|
+
const first = el.shadowRoot.querySelector('.day_date[role="button"] .day-label');
|
|
3612
|
+
expect(first.textContent.trim().startsWith('1')).to.be.true;
|
|
3613
|
+
});
|
|
3614
|
+
it('CS3. a consumer can style the label from outside through the part', async () => {
|
|
3615
|
+
const el = await make();
|
|
3616
|
+
const style = document.createElement('style');
|
|
3617
|
+
style.textContent = 'nile-calendar::part(calendar-day-label){ letter-spacing: 3px; }';
|
|
3618
|
+
document.head.appendChild(style);
|
|
3619
|
+
try {
|
|
3620
|
+
const label = el.shadowRoot.querySelector('.day_date[role="button"] .day-label');
|
|
3621
|
+
expect(getComputedStyle(label).letterSpacing).to.equal('3px');
|
|
3622
|
+
}
|
|
3623
|
+
finally {
|
|
3624
|
+
style.remove();
|
|
3625
|
+
}
|
|
3626
|
+
});
|
|
3627
|
+
it('CS4. the default calendar keeps its published day markup: inline position:relative, no class, no part', async () => {
|
|
3628
|
+
const el = await fixture(html `<nile-calendar variant="default"></nile-calendar>`);
|
|
3629
|
+
await el.updateComplete;
|
|
3630
|
+
const span = el.shadowRoot.querySelector('.day_date span');
|
|
3631
|
+
expect(span.getAttribute('style')).to.equal('position:relative;');
|
|
3632
|
+
expect(span.hasAttribute('part')).to.be.false;
|
|
3633
|
+
expect(span.classList.contains('day-label')).to.be.false;
|
|
3634
|
+
expect(el.shadowRoot.querySelector('[part="calendar-day-label"]')).to.be.null;
|
|
3635
|
+
expect(el.shadowRoot.querySelector('.base--date-time-picker')).to.be.null;
|
|
3636
|
+
});
|
|
3637
|
+
it('CS5. collapsed: no height-lock class and no style attribute on the picker base', async () => {
|
|
3638
|
+
const el = await make();
|
|
3639
|
+
const base = baseOf(el);
|
|
3640
|
+
expect(el.expanded).to.be.false;
|
|
3641
|
+
expect(base.classList.contains('base--tp-height-locked')).to.be.false;
|
|
3642
|
+
expect(base.hasAttribute('style')).to.be.false;
|
|
3643
|
+
});
|
|
3644
|
+
it('CS6. expanded: the class and the custom property carry the measured collapsed height; no inline height', async () => {
|
|
3645
|
+
const el = await make();
|
|
3646
|
+
const collapsed = baseOf(el).offsetHeight;
|
|
3647
|
+
await expand(el);
|
|
3648
|
+
const base = baseOf(el);
|
|
3649
|
+
expect(base.classList.contains('base--tp-height-locked')).to.be.true;
|
|
3650
|
+
expect(base.style.getPropertyValue('--nile-calendar-tp-locked-height')).to.equal(el.tpLockedHeight);
|
|
3651
|
+
expect(base.style.height, 'no inline height').to.equal('');
|
|
3652
|
+
expect(el.tpLockedHeight).to.equal(`${collapsed}px`);
|
|
3653
|
+
expect(base.offsetHeight, 'rendered height equals the locked collapsed height').to.equal(collapsed);
|
|
3654
|
+
expect(getComputedStyle(base).height).to.equal(`${collapsed}px`);
|
|
3655
|
+
});
|
|
3656
|
+
it('CS7. collapsing again removes the class and the style attribute', async () => {
|
|
3657
|
+
const el = await make();
|
|
3658
|
+
await expand(el);
|
|
3659
|
+
el.expanded = false;
|
|
3660
|
+
await settle(el);
|
|
3661
|
+
const base = baseOf(el);
|
|
3662
|
+
expect(base.classList.contains('base--tp-height-locked')).to.be.false;
|
|
3663
|
+
expect(base.hasAttribute('style')).to.be.false;
|
|
3664
|
+
});
|
|
3665
|
+
it('CS8. relative type is never height-locked', async () => {
|
|
3666
|
+
const el = await make();
|
|
3667
|
+
await expand(el);
|
|
3668
|
+
el.type = 'relative';
|
|
3669
|
+
await settle(el);
|
|
3670
|
+
const base = baseOf(el);
|
|
3671
|
+
expect(base.classList.contains('base--tp-height-locked'), 'expanded may linger on a direct type change; the lock must not').to.be.false;
|
|
3672
|
+
expect(base.hasAttribute('style')).to.be.false;
|
|
3673
|
+
});
|
|
3674
|
+
it('CS9. the locked height can be overridden by a consumer through ::part(calendar-container)', async () => {
|
|
3675
|
+
const el = await make();
|
|
3676
|
+
await expand(el);
|
|
3677
|
+
const style = document.createElement('style');
|
|
3678
|
+
style.textContent = 'nile-calendar::part(calendar-container){ height: 500px; }';
|
|
3679
|
+
document.head.appendChild(style);
|
|
3680
|
+
try {
|
|
3681
|
+
expect(getComputedStyle(baseOf(el)).height).to.equal('500px');
|
|
3682
|
+
}
|
|
3683
|
+
finally {
|
|
3684
|
+
style.remove();
|
|
3685
|
+
}
|
|
3686
|
+
});
|
|
3687
|
+
});
|
|
108
3688
|
//# sourceMappingURL=nile-calendar.test.js.map
|