@lilydesignsystem/angular-date-time-picker 0.1.0
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 +281 -0
- package/fesm2022/lilydesignsystem-angular-date-time-picker.mjs +2153 -0
- package/fesm2022/lilydesignsystem-angular-date-time-picker.mjs.map +1 -0
- package/package.json +48 -0
- package/types/lilydesignsystem-angular-date-time-picker.d.ts +558 -0
- package/types/lilydesignsystem-angular-date-time-picker.d.ts.map +1 -0
|
@@ -0,0 +1,2153 @@
|
|
|
1
|
+
import { NgTemplateOutlet } from '@angular/common';
|
|
2
|
+
import * as i0 from '@angular/core';
|
|
3
|
+
import { Directive, input, model, output, contentChild, TemplateRef, viewChild, inject, ChangeDetectorRef, signal, computed, effect, ChangeDetectionStrategy, Component } from '@angular/core';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Default button glyph: U+1F4C5 CALENDAR, followed by U+FE0E VARIATION
|
|
7
|
+
* SELECTOR-15 to request text (monochrome) presentation.
|
|
8
|
+
*
|
|
9
|
+
* Same construction as locale-picker's globe. The variation selector is a
|
|
10
|
+
* hint, not a guarantee — several platforms ignore it and render the emoji
|
|
11
|
+
* anyway — but where it is honoured the glyph inherits the page's text
|
|
12
|
+
* colour and stops looking like a sticker among the siblings' ◑, "A" and
|
|
13
|
+
* ➤. Written as an escape, never as a bare character: a variation
|
|
14
|
+
* selector has no visual form at all, so a bare one is invisible in an
|
|
15
|
+
* editor and trivially lost to a careless edit.
|
|
16
|
+
*/
|
|
17
|
+
const CALENDAR = "📅︎";
|
|
18
|
+
// -----------------------------------------------------------------
|
|
19
|
+
// Civil-date arithmetic
|
|
20
|
+
//
|
|
21
|
+
// Pure and total: no local-time `Date` values, no throwing, null for
|
|
22
|
+
// "not a date". Exported so the acceptance tests can exercise the
|
|
23
|
+
// arithmetic directly rather than only through the rendered grid.
|
|
24
|
+
// -----------------------------------------------------------------
|
|
25
|
+
/** Zero-pad to `width`. */
|
|
26
|
+
function pad(n, width = 2) {
|
|
27
|
+
return String(Math.abs(n)).padStart(width, "0");
|
|
28
|
+
}
|
|
29
|
+
/** Days in a month. `month` is 1-12. */
|
|
30
|
+
function daysInMonth(year, month) {
|
|
31
|
+
// Day 0 of the next month is the last day of this one.
|
|
32
|
+
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
33
|
+
}
|
|
34
|
+
/** `{2026, 3, 1}` → `"2026-03-01"`. */
|
|
35
|
+
function formatIsoDate(date) {
|
|
36
|
+
return `${pad(date.year, 4)}-${pad(date.month)}-${pad(date.day)}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* `"2026-03-01"` → `{2026, 3, 1}`, or null.
|
|
40
|
+
*
|
|
41
|
+
* Rejects impossible components rather than rolling them over, so
|
|
42
|
+
* `"2026-02-31"` is null and not the 3rd of March. A field that silently
|
|
43
|
+
* reinterprets an impossible date is worse than one that refuses it.
|
|
44
|
+
*/
|
|
45
|
+
function parseIsoDate(text) {
|
|
46
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text.trim());
|
|
47
|
+
if (!m)
|
|
48
|
+
return null;
|
|
49
|
+
const year = Number(m[1]);
|
|
50
|
+
const month = Number(m[2]);
|
|
51
|
+
const day = Number(m[3]);
|
|
52
|
+
if (month < 1 || month > 12)
|
|
53
|
+
return null;
|
|
54
|
+
if (day < 1 || day > daysInMonth(year, month))
|
|
55
|
+
return null;
|
|
56
|
+
return { year, month, day };
|
|
57
|
+
}
|
|
58
|
+
/** Days since the Unix epoch. The unit all date arithmetic goes through. */
|
|
59
|
+
function toEpochDay(date) {
|
|
60
|
+
return Date.UTC(date.year, date.month - 1, date.day) / 86400000;
|
|
61
|
+
}
|
|
62
|
+
/** Inverse of `toEpochDay`. */
|
|
63
|
+
function fromEpochDay(epochDay) {
|
|
64
|
+
const d = new Date(epochDay * 86400000);
|
|
65
|
+
return {
|
|
66
|
+
year: d.getUTCFullYear(),
|
|
67
|
+
month: d.getUTCMonth() + 1,
|
|
68
|
+
day: d.getUTCDate(),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** Shift an ISO date by whole days. */
|
|
72
|
+
function addDays(isoDate, days) {
|
|
73
|
+
const date = parseIsoDate(isoDate);
|
|
74
|
+
if (!date)
|
|
75
|
+
return isoDate;
|
|
76
|
+
return formatIsoDate(fromEpochDay(toEpochDay(date) + days));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Shift an ISO date by whole months, clamping the day.
|
|
80
|
+
*
|
|
81
|
+
* 31 January + 1 month is 28 February (29 in a leap year), not 3 March.
|
|
82
|
+
* Rolling over is what `Date.prototype.setMonth` does and it is almost
|
|
83
|
+
* never what someone paging a calendar means.
|
|
84
|
+
*/
|
|
85
|
+
function addMonths(isoDate, months) {
|
|
86
|
+
const date = parseIsoDate(isoDate);
|
|
87
|
+
if (!date)
|
|
88
|
+
return isoDate;
|
|
89
|
+
const total = date.year * 12 + (date.month - 1) + months;
|
|
90
|
+
const year = Math.floor(total / 12);
|
|
91
|
+
const month = (((total % 12) + 12) % 12) + 1;
|
|
92
|
+
return formatIsoDate({
|
|
93
|
+
year,
|
|
94
|
+
month,
|
|
95
|
+
day: Math.min(date.day, daysInMonth(year, month)),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** Day of week: 0 = Sunday … 6 = Saturday. */
|
|
99
|
+
function weekdayOf(isoDate) {
|
|
100
|
+
const date = parseIsoDate(isoDate);
|
|
101
|
+
if (!date)
|
|
102
|
+
return 0;
|
|
103
|
+
return new Date(Date.UTC(date.year, date.month - 1, date.day)).getUTCDay();
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* ISO-8601 week number.
|
|
107
|
+
*
|
|
108
|
+
* Weeks start Monday and week 1 is the one containing the first Thursday,
|
|
109
|
+
* which is why this pivots on Thursday rather than counting from 1
|
|
110
|
+
* January. Public-sector and clinical rotas quote ISO week numbers, so
|
|
111
|
+
* the column is worth getting exactly right.
|
|
112
|
+
*/
|
|
113
|
+
function isoWeek(isoDate) {
|
|
114
|
+
if (!parseIsoDate(isoDate))
|
|
115
|
+
return 0;
|
|
116
|
+
const mondayIndex = (weekdayOf(isoDate) + 6) % 7;
|
|
117
|
+
const thursday = addDays(isoDate, 3 - mondayIndex);
|
|
118
|
+
const parsed = parseIsoDate(thursday);
|
|
119
|
+
if (!parsed)
|
|
120
|
+
return 0;
|
|
121
|
+
const jan1 = parseIsoDate(formatIsoDate({ year: parsed.year, month: 1, day: 1 }));
|
|
122
|
+
if (!jan1)
|
|
123
|
+
return 0;
|
|
124
|
+
return Math.floor((toEpochDay(parsed) - toEpochDay(jan1)) / 7) + 1;
|
|
125
|
+
}
|
|
126
|
+
/** `"09:30"` → `{9, 30}`, or null. */
|
|
127
|
+
function parseIsoTime(text) {
|
|
128
|
+
const m = /^(\d{2}):(\d{2})$/.exec(text.trim());
|
|
129
|
+
if (!m)
|
|
130
|
+
return null;
|
|
131
|
+
const hour = Number(m[1]);
|
|
132
|
+
const minute = Number(m[2]);
|
|
133
|
+
if (hour > 23 || minute > 59)
|
|
134
|
+
return null;
|
|
135
|
+
return { hour, minute };
|
|
136
|
+
}
|
|
137
|
+
/** `{9, 30}` → `"09:30"`. */
|
|
138
|
+
function formatIsoTime(time) {
|
|
139
|
+
return `${pad(time.hour)}:${pad(time.minute)}`;
|
|
140
|
+
}
|
|
141
|
+
/** Pull the date and time halves out of a mode-appropriate ISO value. */
|
|
142
|
+
function splitValue(value, mode) {
|
|
143
|
+
if (!value)
|
|
144
|
+
return { date: "", time: "" };
|
|
145
|
+
if (mode === "time") {
|
|
146
|
+
return { date: "", time: parseIsoTime(value) ? value : "" };
|
|
147
|
+
}
|
|
148
|
+
const [datePart = "", timePart = ""] = value.split("T");
|
|
149
|
+
return {
|
|
150
|
+
date: parseIsoDate(datePart) ? datePart : "",
|
|
151
|
+
time: mode === "datetime" && parseIsoTime(timePart) ? timePart : "",
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/** Recombine the halves. Returns "" when the value is incomplete. */
|
|
155
|
+
function joinValue(date, time, mode) {
|
|
156
|
+
if (mode === "date")
|
|
157
|
+
return date;
|
|
158
|
+
if (mode === "time")
|
|
159
|
+
return time;
|
|
160
|
+
return date && time ? `${date}T${time}` : "";
|
|
161
|
+
}
|
|
162
|
+
/** Is `isoDate` inside the inclusive [min, max] window? Empty bounds pass. */
|
|
163
|
+
function withinRange(isoDate, min, max) {
|
|
164
|
+
if (min && isoDate < min)
|
|
165
|
+
return false;
|
|
166
|
+
if (max && isoDate > max)
|
|
167
|
+
return false;
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
/** Uppercase region subtag of a BCP 47 tag, or "". */
|
|
171
|
+
function regionOf(locale) {
|
|
172
|
+
if (!locale)
|
|
173
|
+
return "";
|
|
174
|
+
for (const part of locale.split(/[-_]/).slice(1)) {
|
|
175
|
+
if (/^[A-Za-z]{2}$/.test(part))
|
|
176
|
+
return part.toUpperCase();
|
|
177
|
+
}
|
|
178
|
+
return "";
|
|
179
|
+
}
|
|
180
|
+
const SUNDAY_FIRST_REGIONS = new Set([
|
|
181
|
+
"AR", "BR", "CA", "CL", "CO", "DO", "GT", "HK", "IL", "IN", "JP",
|
|
182
|
+
"KR", "MO", "MX", "PE", "PH", "PK", "TH", "TW", "US", "VE", "ZA",
|
|
183
|
+
]);
|
|
184
|
+
const SATURDAY_FIRST_REGIONS = new Set([
|
|
185
|
+
"AE", "AF", "BH", "DJ", "DZ", "EG", "IQ", "IR", "JO", "KW", "LY",
|
|
186
|
+
"OM", "QA", "SA", "SD", "SY", "YE",
|
|
187
|
+
]);
|
|
188
|
+
/**
|
|
189
|
+
* First day of the week for a locale: 0 = Sunday … 6 = Saturday.
|
|
190
|
+
*
|
|
191
|
+
* `Intl.Locale.prototype.getWeekInfo` is the right answer, but it is
|
|
192
|
+
* recent enough that a fallback still earns its place — and it is
|
|
193
|
+
* missing from some SSR runtimes entirely. The fallback is a short
|
|
194
|
+
* region table plus a Monday default, Monday being both the ISO-8601
|
|
195
|
+
* rule and the majority convention worldwide.
|
|
196
|
+
*/
|
|
197
|
+
function firstDayOfWeekFor(locale) {
|
|
198
|
+
if (locale) {
|
|
199
|
+
try {
|
|
200
|
+
const loc = new Intl.Locale(locale);
|
|
201
|
+
const info = loc.getWeekInfo?.() ?? loc.weekInfo;
|
|
202
|
+
// getWeekInfo reports 1 = Monday … 7 = Sunday, so Sunday (7) has to
|
|
203
|
+
// fold to 0.
|
|
204
|
+
if (info && typeof info.firstDay === "number") {
|
|
205
|
+
return info.firstDay % 7;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// Malformed tag — fall through to the table.
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const region = regionOf(locale);
|
|
213
|
+
if (SUNDAY_FIRST_REGIONS.has(region))
|
|
214
|
+
return 0;
|
|
215
|
+
if (SATURDAY_FIRST_REGIONS.has(region))
|
|
216
|
+
return 6;
|
|
217
|
+
return 1;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* The dates of one month's grid, always six rows of seven.
|
|
221
|
+
*
|
|
222
|
+
* Fixed height on purpose. A grid sized to its month runs four to six
|
|
223
|
+
* rows, so the footer — and the confirm button in it — moves vertically
|
|
224
|
+
* as you page months. That is a Fitts's-law tax on every subsequent click
|
|
225
|
+
* and it makes the dialog jump under the pointer. A constant six rows
|
|
226
|
+
* costs at most one extra trailing week.
|
|
227
|
+
*/
|
|
228
|
+
function monthMatrix(year, month, firstDayOfWeek) {
|
|
229
|
+
const first = formatIsoDate({ year, month, day: 1 });
|
|
230
|
+
const lead = (weekdayOf(first) - firstDayOfWeek + 7) % 7;
|
|
231
|
+
const start = addDays(first, -lead);
|
|
232
|
+
const weeks = [];
|
|
233
|
+
for (let row = 0; row < 6; row++) {
|
|
234
|
+
const week = [];
|
|
235
|
+
for (let col = 0; col < 7; col++) {
|
|
236
|
+
week.push(addDays(start, row * 7 + col));
|
|
237
|
+
}
|
|
238
|
+
weeks.push(week);
|
|
239
|
+
}
|
|
240
|
+
return weeks;
|
|
241
|
+
}
|
|
242
|
+
/** Long and short month names for a locale, index 0 = January. */
|
|
243
|
+
function monthNames(locale) {
|
|
244
|
+
const build = (month) => {
|
|
245
|
+
try {
|
|
246
|
+
const fmt = new Intl.DateTimeFormat(locale, {
|
|
247
|
+
month,
|
|
248
|
+
timeZone: "UTC",
|
|
249
|
+
});
|
|
250
|
+
return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(Date.UTC(2021, i, 15))));
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
return { long: build("long"), short: build("short") };
|
|
257
|
+
}
|
|
258
|
+
/** Match a token against a locale's month names. Returns 1-12, or 0. */
|
|
259
|
+
function matchMonthName(token, names) {
|
|
260
|
+
const norm = (s) => s.toLocaleLowerCase().replace(/\.$/, "").normalize("NFKD");
|
|
261
|
+
const t = norm(token);
|
|
262
|
+
if (!t || /^\d+$/.test(t))
|
|
263
|
+
return 0;
|
|
264
|
+
for (let i = 0; i < 12; i++) {
|
|
265
|
+
if (norm(names.long[i] ?? "") === t)
|
|
266
|
+
return i + 1;
|
|
267
|
+
if (norm(names.short[i] ?? "") === t)
|
|
268
|
+
return i + 1;
|
|
269
|
+
}
|
|
270
|
+
// Prefix match, so "Sept" finds September. Three characters minimum:
|
|
271
|
+
// "Ma" cannot choose between March and May, and guessing there would be
|
|
272
|
+
// worse than refusing.
|
|
273
|
+
if (t.length >= 3) {
|
|
274
|
+
for (let i = 0; i < 12; i++) {
|
|
275
|
+
const long = norm(names.long[i] ?? "");
|
|
276
|
+
if (long && long.startsWith(t))
|
|
277
|
+
return i + 1;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return 0;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* The order a locale writes a numeric date in — `["day","month","year"]`
|
|
284
|
+
* for en-GB, `["month","day","year"]` for en-US.
|
|
285
|
+
*/
|
|
286
|
+
function numericFieldOrder(locale) {
|
|
287
|
+
try {
|
|
288
|
+
const parts = new Intl.DateTimeFormat(locale, {
|
|
289
|
+
day: "2-digit",
|
|
290
|
+
month: "2-digit",
|
|
291
|
+
year: "numeric",
|
|
292
|
+
timeZone: "UTC",
|
|
293
|
+
}).formatToParts(new Date(Date.UTC(2021, 4, 6)));
|
|
294
|
+
const order = parts
|
|
295
|
+
.map((p) => p.type)
|
|
296
|
+
.filter((t) => t === "day" || t === "month" || t === "year");
|
|
297
|
+
if (order.length === 3)
|
|
298
|
+
return order;
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
// Fall through.
|
|
302
|
+
}
|
|
303
|
+
return ["day", "month", "year"];
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Parse typed text into an ISO date.
|
|
307
|
+
*
|
|
308
|
+
* Accepts, in order: ISO `YYYY-MM-DD`; a numeric form whose field order
|
|
309
|
+
* follows the locale, so `03/04/2026` is 3 April in en-GB and 4 March in
|
|
310
|
+
* en-US — which is what each user means; and a form with a written month
|
|
311
|
+
* matched against the locale's own long and short month names, which is
|
|
312
|
+
* what lets DHCW's `27-Jun-2025` round-trip.
|
|
313
|
+
*
|
|
314
|
+
* Anything else is null. Guessing harder than this is how date fields
|
|
315
|
+
* silently record the wrong day.
|
|
316
|
+
*/
|
|
317
|
+
function parseDateInput(text, locale) {
|
|
318
|
+
const trimmed = text.trim();
|
|
319
|
+
if (!trimmed)
|
|
320
|
+
return null;
|
|
321
|
+
const iso = parseIsoDate(trimmed);
|
|
322
|
+
if (iso)
|
|
323
|
+
return formatIsoDate(iso);
|
|
324
|
+
const parts = trimmed.split(/[\s./-]+/).filter(Boolean);
|
|
325
|
+
if (parts.length !== 3)
|
|
326
|
+
return null;
|
|
327
|
+
// Look for a written month first. Whichever field it is, the other two
|
|
328
|
+
// are the day and the year, and their order is then decidable without
|
|
329
|
+
// knowing the locale: the larger number is the year.
|
|
330
|
+
const names = monthNames(locale);
|
|
331
|
+
let month = 0;
|
|
332
|
+
let monthIndex = -1;
|
|
333
|
+
for (let i = 0; i < parts.length; i++) {
|
|
334
|
+
const found = matchMonthName(parts[i], names);
|
|
335
|
+
if (found) {
|
|
336
|
+
month = found;
|
|
337
|
+
monthIndex = i;
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
let day = 0;
|
|
342
|
+
let year = 0;
|
|
343
|
+
if (monthIndex >= 0) {
|
|
344
|
+
const rest = parts
|
|
345
|
+
.filter((_, i) => i !== monthIndex)
|
|
346
|
+
.map((p) => Number(p));
|
|
347
|
+
if (rest.some((n) => Number.isNaN(n)))
|
|
348
|
+
return null;
|
|
349
|
+
[day, year] = rest[0] > rest[1] ? [rest[1], rest[0]] : rest;
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
const nums = parts.map((p) => Number(p));
|
|
353
|
+
if (nums.some((n) => Number.isNaN(n)))
|
|
354
|
+
return null;
|
|
355
|
+
const order = numericFieldOrder(locale);
|
|
356
|
+
year = nums[order.indexOf("year")];
|
|
357
|
+
day = nums[order.indexOf("day")];
|
|
358
|
+
month = nums[order.indexOf("month")];
|
|
359
|
+
}
|
|
360
|
+
// Two-digit years: the usual 70 pivot. Documented rather than clever,
|
|
361
|
+
// because any choice here is wrong for someone.
|
|
362
|
+
if (year < 100)
|
|
363
|
+
year += year < 70 ? 2000 : 1900;
|
|
364
|
+
if (month < 1 || month > 12)
|
|
365
|
+
return null;
|
|
366
|
+
if (day < 1 || day > daysInMonth(year, month))
|
|
367
|
+
return null;
|
|
368
|
+
return formatIsoDate({ year, month, day });
|
|
369
|
+
}
|
|
370
|
+
/** Accepts `9:30`, `09:30`, `0930`, `9.30`, and a trailing am/pm. */
|
|
371
|
+
function parseTimeInput(text) {
|
|
372
|
+
const m = /^(\d{1,2})[:.]?(\d{2})\s*([ap])\.?m\.?$|^(\d{1,2})[:.]?(\d{2})$/.exec(text.trim().toLowerCase());
|
|
373
|
+
if (!m)
|
|
374
|
+
return null;
|
|
375
|
+
let hour = Number(m[1] ?? m[4]);
|
|
376
|
+
const minute = Number(m[2] ?? m[5]);
|
|
377
|
+
const meridiem = m[3];
|
|
378
|
+
if (meridiem === "p" && hour < 12)
|
|
379
|
+
hour += 12;
|
|
380
|
+
if (meridiem === "a" && hour === 12)
|
|
381
|
+
hour = 0;
|
|
382
|
+
if (hour > 23 || minute > 59)
|
|
383
|
+
return null;
|
|
384
|
+
return formatIsoTime({ hour, minute });
|
|
385
|
+
}
|
|
386
|
+
/** Does this locale write times on a 12-hour clock? */
|
|
387
|
+
function localeUsesHour12(loc) {
|
|
388
|
+
try {
|
|
389
|
+
const parts = new Intl.DateTimeFormat(loc, {
|
|
390
|
+
hour: "numeric",
|
|
391
|
+
timeZone: "UTC",
|
|
392
|
+
}).formatToParts(new Date(Date.UTC(2021, 0, 1, 13)));
|
|
393
|
+
return parts.some((p) => p.type === "dayPeriod");
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/** The host's current calendar day, read via the *local* getters. */
|
|
400
|
+
function todayIso() {
|
|
401
|
+
const now = new Date();
|
|
402
|
+
return formatIsoDate({
|
|
403
|
+
year: now.getFullYear(),
|
|
404
|
+
month: now.getMonth() + 1,
|
|
405
|
+
day: now.getDate(),
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
let uid = 0;
|
|
409
|
+
/** Stable per-instance id prefix; SSR-safe (no Math.random / Date.now). */
|
|
410
|
+
function nextDateTimePickerId() {
|
|
411
|
+
uid += 1;
|
|
412
|
+
return `date-time-picker-${uid}`;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Optional marker for the projected icon template. Gives consumers typed
|
|
416
|
+
* `let-` variables:
|
|
417
|
+
*
|
|
418
|
+
* ```html
|
|
419
|
+
* <lily-date-time-picker label="Choose a date" [labels]="labels">
|
|
420
|
+
* <ng-template lilyDateTimePickerIcon let-args>{{ args.open ? "×" : "📅" }}</ng-template>
|
|
421
|
+
* </lily-date-time-picker>
|
|
422
|
+
* ```
|
|
423
|
+
*
|
|
424
|
+
* The component queries any projected `<ng-template>`, so the marker is
|
|
425
|
+
* for type-checking and readability, not for matching.
|
|
426
|
+
*/
|
|
427
|
+
class DateTimePickerIcon {
|
|
428
|
+
static ngTemplateContextGuard(_dir, _ctx) {
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.4", ngImport: i0, type: DateTimePickerIcon, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
432
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.4", type: DateTimePickerIcon, isStandalone: true, selector: "ng-template[lilyDateTimePickerIcon]", ngImport: i0 }); }
|
|
433
|
+
}
|
|
434
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImport: i0, type: DateTimePickerIcon, decorators: [{
|
|
435
|
+
type: Directive,
|
|
436
|
+
args: [{
|
|
437
|
+
selector: "ng-template[lilyDateTimePickerIcon]",
|
|
438
|
+
standalone: true,
|
|
439
|
+
}]
|
|
440
|
+
}] });
|
|
441
|
+
/**
|
|
442
|
+
* DateTimePicker — a headless date / time / datetime control.
|
|
443
|
+
*
|
|
444
|
+
* A typeable text field plus an icon button that opens a WAI-ARIA APG
|
|
445
|
+
* **Date Picker Dialog**: a month grid with a full keyboard contract.
|
|
446
|
+
* Locale-correct by construction — month names, weekday names, first day
|
|
447
|
+
* of week, numeric field order, 12- vs 24-hour clock and day-period names
|
|
448
|
+
* all come from `Intl`. See `spec/index.md` for the full contract.
|
|
449
|
+
*
|
|
450
|
+
* Unlike the three preference helpers this is a **form control**, not a
|
|
451
|
+
* page-header preference control, and it persists nothing.
|
|
452
|
+
*/
|
|
453
|
+
class DateTimePicker {
|
|
454
|
+
constructor() {
|
|
455
|
+
/** Accessible name for the trigger button and the dialog. */
|
|
456
|
+
this.label = input.required(/* @ts-ignore */
|
|
457
|
+
...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
|
|
458
|
+
/** Every other user-facing string. See `spec/index.md` §4.2. */
|
|
459
|
+
this.labels = input.required(/* @ts-ignore */
|
|
460
|
+
...(ngDevMode ? [{ debugName: "labels" }] : /* istanbul ignore next */ []));
|
|
461
|
+
/** What to collect: a date, a time, or both. */
|
|
462
|
+
this.mode = input("date", /* @ts-ignore */
|
|
463
|
+
...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ []));
|
|
464
|
+
/** ISO value: `YYYY-MM-DD`, `HH:MM`, or `YYYY-MM-DDTHH:MM`. Two-way bindable. */
|
|
465
|
+
this.value = model("", /* @ts-ignore */
|
|
466
|
+
...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
|
|
467
|
+
/** BCP 47 tag driving month names, weekday names and first weekday. */
|
|
468
|
+
this.locale = input("", /* @ts-ignore */
|
|
469
|
+
...(ngDevMode ? [{ debugName: "locale" }] : /* istanbul ignore next */ []));
|
|
470
|
+
/** Earliest selectable date, ISO. */
|
|
471
|
+
this.min = input("", /* @ts-ignore */
|
|
472
|
+
...(ngDevMode ? [{ debugName: "min" }] : /* istanbul ignore next */ []));
|
|
473
|
+
/** Latest selectable date, ISO. */
|
|
474
|
+
this.max = input("", /* @ts-ignore */
|
|
475
|
+
...(ngDevMode ? [{ debugName: "max" }] : /* istanbul ignore next */ []));
|
|
476
|
+
/** Veto individual dates — true means "cannot be picked". */
|
|
477
|
+
this.isDateDisabled = input(undefined, /* @ts-ignore */
|
|
478
|
+
...(ngDevMode ? [{ debugName: "isDateDisabled" }] : /* istanbul ignore next */ []));
|
|
479
|
+
/** 0 = Sunday … 6 = Saturday. Defaults to the locale's convention. */
|
|
480
|
+
this.firstDayOfWeek = input(undefined, /* @ts-ignore */
|
|
481
|
+
...(ngDevMode ? [{ debugName: "firstDayOfWeek" }] : /* istanbul ignore next */ []));
|
|
482
|
+
/** Granularity of the minute select. */
|
|
483
|
+
this.minuteStep = input(1, /* @ts-ignore */
|
|
484
|
+
...(ngDevMode ? [{ debugName: "minuteStep" }] : /* istanbul ignore next */ []));
|
|
485
|
+
/** 12-hour clock. Defaults to the locale's convention. */
|
|
486
|
+
this.hour12 = input(undefined, /* @ts-ignore */
|
|
487
|
+
...(ngDevMode ? [{ debugName: "hour12" }] : /* istanbul ignore next */ []));
|
|
488
|
+
/** Render an ISO-8601 week-number column. */
|
|
489
|
+
this.showWeekNumbers = input(false, /* @ts-ignore */
|
|
490
|
+
...(ngDevMode ? [{ debugName: "showWeekNumbers" }] : /* istanbul ignore next */ []));
|
|
491
|
+
/** Quick-pick buttons. */
|
|
492
|
+
this.shortcuts = input([], /* @ts-ignore */
|
|
493
|
+
...(ngDevMode ? [{ debugName: "shortcuts" }] : /* istanbul ignore next */ []));
|
|
494
|
+
/** Commit and close as soon as a day is chosen. Defaults to date-only. */
|
|
495
|
+
this.confirmOnSelect = input(undefined, /* @ts-ignore */
|
|
496
|
+
...(ngDevMode ? [{ debugName: "confirmOnSelect" }] : /* istanbul ignore next */ []));
|
|
497
|
+
/** `name` of the hidden input that carries the value in a form post. */
|
|
498
|
+
this.name = input("date-time", /* @ts-ignore */
|
|
499
|
+
...(ngDevMode ? [{ debugName: "name" }] : /* istanbul ignore next */ []));
|
|
500
|
+
/**
|
|
501
|
+
* Selected IANA time zone (e.g. `Europe/London`), or `""` for none.
|
|
502
|
+
* Two-way bindable via `[(timeZone)]`, matching `value`. Rides its own
|
|
503
|
+
* hidden input `{name}-time-zone` and is reflected as `data-time-zone`
|
|
504
|
+
* on the root. It is metadata about WHERE the civil value applies, not
|
|
505
|
+
* part of the value — converting to an instant stays the consumer's
|
|
506
|
+
* job. Never guessed from the runtime: the picker no more picks a zone
|
|
507
|
+
* than `locale-picker` picks a locale.
|
|
508
|
+
*/
|
|
509
|
+
this.timeZone = model("", /* @ts-ignore */
|
|
510
|
+
...(ngDevMode ? [{ debugName: "timeZone" }] : /* istanbul ignore next */ []));
|
|
511
|
+
/**
|
|
512
|
+
* Zones offered by the select. Defaults to every zone the runtime
|
|
513
|
+
* knows via `Intl.supportedValuesOf("timeZone")` — never a bundled
|
|
514
|
+
* table, the rule month and weekday names already follow.
|
|
515
|
+
*/
|
|
516
|
+
this.timeZones = input(undefined, /* @ts-ignore */
|
|
517
|
+
...(ngDevMode ? [{ debugName: "timeZones" }] : /* istanbul ignore next */ []));
|
|
518
|
+
/** Display text per zone id; a zone without an entry shows its id. */
|
|
519
|
+
this.timeZoneLabels = input({}, /* @ts-ignore */
|
|
520
|
+
...(ngDevMode ? [{ debugName: "timeZoneLabels" }] : /* istanbul ignore next */ []));
|
|
521
|
+
/** `id` of the text field, so a consumer `<label for>` can name it. */
|
|
522
|
+
this.inputId = input("", /* @ts-ignore */
|
|
523
|
+
...(ngDevMode ? [{ debugName: "inputId" }] : /* istanbul ignore next */ []));
|
|
524
|
+
/** Forwarded to the text field as `aria-describedby`. */
|
|
525
|
+
this.describedBy = input("", /* @ts-ignore */
|
|
526
|
+
...(ngDevMode ? [{ debugName: "describedBy" }] : /* istanbul ignore next */ []));
|
|
527
|
+
/** Placeholder for the text field. */
|
|
528
|
+
this.placeholder = input("", /* @ts-ignore */
|
|
529
|
+
...(ngDevMode ? [{ debugName: "placeholder" }] : /* istanbul ignore next */ []));
|
|
530
|
+
/** Disable the whole control. */
|
|
531
|
+
this.disabled = input(false, /* @ts-ignore */
|
|
532
|
+
...(ngDevMode ? [{ debugName: "disabled" }] : /* istanbul ignore next */ []));
|
|
533
|
+
/**
|
|
534
|
+
* Show the value but refuse edits.
|
|
535
|
+
*
|
|
536
|
+
* Named `readOnly` (not `readonly`) so the Angular input has an
|
|
537
|
+
* unambiguous identifier distinct from the TypeScript `readonly`
|
|
538
|
+
* modifier — the one Angular-forced rename beyond `class` → `className`.
|
|
539
|
+
*/
|
|
540
|
+
this.readOnly = input(false, /* @ts-ignore */
|
|
541
|
+
...(ngDevMode ? [{ debugName: "readOnly" }] : /* istanbul ignore next */ []));
|
|
542
|
+
/** Mark the field required. */
|
|
543
|
+
this.required = input(false, /* @ts-ignore */
|
|
544
|
+
...(ngDevMode ? [{ debugName: "required" }] : /* istanbul ignore next */ []));
|
|
545
|
+
/** Override how a committed value is rendered into the text field. */
|
|
546
|
+
this.formatValue = input(undefined, /* @ts-ignore */
|
|
547
|
+
...(ngDevMode ? [{ debugName: "formatValue" }] : /* istanbul ignore next */ []));
|
|
548
|
+
/** Override how typed text is turned back into an ISO value. */
|
|
549
|
+
this.parseInput = input(undefined, /* @ts-ignore */
|
|
550
|
+
...(ngDevMode ? [{ debugName: "parseInput" }] : /* istanbul ignore next */ []));
|
|
551
|
+
/** Extra CSS class on the root `<div>`. */
|
|
552
|
+
this.className = input("", /* @ts-ignore */
|
|
553
|
+
...(ngDevMode ? [{ debugName: "className" }] : /* istanbul ignore next */ []));
|
|
554
|
+
/** Fires after a new value is committed. */
|
|
555
|
+
this.change = output();
|
|
556
|
+
/** Fires when a shortcut is used, before the value settles. */
|
|
557
|
+
this.shortcut = output();
|
|
558
|
+
/** Fires when typed text cannot be parsed. */
|
|
559
|
+
this.invalidInput = output();
|
|
560
|
+
/** Projected icon template; replaces the default glyph when supplied. */
|
|
561
|
+
this.iconTemplate = contentChild(TemplateRef, /* @ts-ignore */
|
|
562
|
+
...(ngDevMode ? [{ debugName: "iconTemplate" }] : /* istanbul ignore next */ []));
|
|
563
|
+
this.rootRef = viewChild.required("rootEl", /* @ts-ignore */
|
|
564
|
+
...(ngDevMode ? [{ debugName: "rootRef" }] : /* istanbul ignore next */ []));
|
|
565
|
+
this.buttonRef = viewChild.required("buttonEl", /* @ts-ignore */
|
|
566
|
+
...(ngDevMode ? [{ debugName: "buttonRef" }] : /* istanbul ignore next */ []));
|
|
567
|
+
this.dialogRef = viewChild.required("dialogEl", /* @ts-ignore */
|
|
568
|
+
...(ngDevMode ? [{ debugName: "dialogRef" }] : /* istanbul ignore next */ []));
|
|
569
|
+
/** Optional: the grid renders only when `mode` includes a date. */
|
|
570
|
+
this.gridRef = viewChild("gridEl", /* @ts-ignore */
|
|
571
|
+
...(ngDevMode ? [{ debugName: "gridRef" }] : /* istanbul ignore next */ []));
|
|
572
|
+
this.glyph = CALENDAR;
|
|
573
|
+
/**
|
|
574
|
+
* For rendering the view synchronously before a focus move.
|
|
575
|
+
*
|
|
576
|
+
* Svelte updates the DOM synchronously when state changes, so the
|
|
577
|
+
* canonical helper can focus a freshly-paged month from a microtask.
|
|
578
|
+
* Angular renders later — after the microtask queue drains, whether the
|
|
579
|
+
* app is zone-based or zoneless — so a queued focus into a month that
|
|
580
|
+
* has not rendered yet finds nothing (or, worse, finds and focuses a
|
|
581
|
+
* cell that the next render destroys, dropping focus to `<body>`).
|
|
582
|
+
* `cdr.detectChanges()` inside the event handler is the Angular
|
|
583
|
+
* equivalent of Svelte's synchronous flush.
|
|
584
|
+
*/
|
|
585
|
+
this.cdr = inject(ChangeDetectorRef);
|
|
586
|
+
this.baseId = nextDateTimePickerId();
|
|
587
|
+
this.dialogId = `${this.baseId}-dialog`;
|
|
588
|
+
this.periodId = `${this.baseId}-period`;
|
|
589
|
+
this.hourId = `${this.baseId}-hour`;
|
|
590
|
+
this.minuteId = `${this.baseId}-minute`;
|
|
591
|
+
this.meridiemId = `${this.baseId}-meridiem`;
|
|
592
|
+
this.statusId = `${this.baseId}-status`;
|
|
593
|
+
this.instructionsId = `${this.baseId}-instructions`;
|
|
594
|
+
this.timeZoneId = `${this.baseId}-time-zone`;
|
|
595
|
+
this.open = signal(false, /* @ts-ignore */
|
|
596
|
+
...(ngDevMode ? [{ debugName: "open" }] : /* istanbul ignore next */ []));
|
|
597
|
+
this.invalid = signal(false, /* @ts-ignore */
|
|
598
|
+
...(ngDevMode ? [{ debugName: "invalid" }] : /* istanbul ignore next */ []));
|
|
599
|
+
/**
|
|
600
|
+
* The element that opened the dialog, so close can return focus to it.
|
|
601
|
+
*
|
|
602
|
+
* The APG rule is "focus returns to the element that invoked the
|
|
603
|
+
* dialog" — which is the trigger button on a click, but the *text
|
|
604
|
+
* field* when the user pressed Alt+ArrowDown. Always refocusing the
|
|
605
|
+
* button strands a keyboard user one Tab stop past where they were.
|
|
606
|
+
*/
|
|
607
|
+
this.openerEl = null;
|
|
608
|
+
/**
|
|
609
|
+
* Text the user has typed but not yet resolved.
|
|
610
|
+
*
|
|
611
|
+
* `null` means "no pending edit, show the formatted value". An empty
|
|
612
|
+
* string is a real state (the user cleared the field) and must not
|
|
613
|
+
* collapse into `null`.
|
|
614
|
+
*/
|
|
615
|
+
this.typed = signal(null, /* @ts-ignore */
|
|
616
|
+
...(ngDevMode ? [{ debugName: "typed" }] : /* istanbul ignore next */ []));
|
|
617
|
+
/**
|
|
618
|
+
* The pending selection, live only while the dialog is open.
|
|
619
|
+
*
|
|
620
|
+
* Kept separate from `value` so Cancel and Escape can genuinely revert.
|
|
621
|
+
* A picker that writes through on every arrow key has no cancel — only
|
|
622
|
+
* an undo the user has to perform by hand, from memory.
|
|
623
|
+
*/
|
|
624
|
+
this.pendingDate = signal("", /* @ts-ignore */
|
|
625
|
+
...(ngDevMode ? [{ debugName: "pendingDate" }] : /* istanbul ignore next */ []));
|
|
626
|
+
this.pendingTime = signal("", /* @ts-ignore */
|
|
627
|
+
...(ngDevMode ? [{ debugName: "pendingTime" }] : /* istanbul ignore next */ []));
|
|
628
|
+
/** The month on screen. */
|
|
629
|
+
this.viewYear = signal(1970, /* @ts-ignore */
|
|
630
|
+
...(ngDevMode ? [{ debugName: "viewYear" }] : /* istanbul ignore next */ []));
|
|
631
|
+
this.viewMonth = signal(1, /* @ts-ignore */
|
|
632
|
+
...(ngDevMode ? [{ debugName: "viewMonth" }] : /* istanbul ignore next */ []));
|
|
633
|
+
/** The day the grid's roving tabindex sits on. */
|
|
634
|
+
this.cursor = signal("", /* @ts-ignore */
|
|
635
|
+
...(ngDevMode ? [{ debugName: "cursor" }] : /* istanbul ignore next */ []));
|
|
636
|
+
/**
|
|
637
|
+
* Today, sampled once at open and refreshed on the initial effect below.
|
|
638
|
+
* Not re-read per cell: the grid asks "is this today?" 42 times a
|
|
639
|
+
* render, and a clock read per cell buys nothing over a clock read per
|
|
640
|
+
* open.
|
|
641
|
+
*/
|
|
642
|
+
this.today = signal("", /* @ts-ignore */
|
|
643
|
+
...(ngDevMode ? [{ debugName: "today" }] : /* istanbul ignore next */ []));
|
|
644
|
+
this.fieldId = computed(() => this.inputId() || `${this.baseId}-input`, /* @ts-ignore */
|
|
645
|
+
...(ngDevMode ? [{ debugName: "fieldId" }] : /* istanbul ignore next */ []));
|
|
646
|
+
this.committed = computed(() => splitValue(this.value(), this.mode()), /* @ts-ignore */
|
|
647
|
+
...(ngDevMode ? [{ debugName: "committed" }] : /* istanbul ignore next */ []));
|
|
648
|
+
this.weekStart = computed(() => this.firstDayOfWeek() ?? firstDayOfWeekFor(this.locale() || undefined), /* @ts-ignore */
|
|
649
|
+
...(ngDevMode ? [{ debugName: "weekStart" }] : /* istanbul ignore next */ []));
|
|
650
|
+
this.commitOnDay = computed(() => this.confirmOnSelect() ?? this.mode() === "date", /* @ts-ignore */
|
|
651
|
+
...(ngDevMode ? [{ debugName: "commitOnDay" }] : /* istanbul ignore next */ []));
|
|
652
|
+
this.usesDate = computed(() => this.mode() !== "time", /* @ts-ignore */
|
|
653
|
+
...(ngDevMode ? [{ debugName: "usesDate" }] : /* istanbul ignore next */ []));
|
|
654
|
+
this.usesTime = computed(() => this.mode() !== "date", /* @ts-ignore */
|
|
655
|
+
...(ngDevMode ? [{ debugName: "usesTime" }] : /* istanbul ignore next */ []));
|
|
656
|
+
this.clock12 = computed(() => this.hour12() ?? localeUsesHour12(this.locale() || undefined), /* @ts-ignore */
|
|
657
|
+
...(ngDevMode ? [{ debugName: "clock12" }] : /* istanbul ignore next */ []));
|
|
658
|
+
/**
|
|
659
|
+
* `aria-describedby` for the field: the consumer's hint, plus — while
|
|
660
|
+
* invalid, with `labels().invalid` supplied — the status region, for
|
|
661
|
+
* the assistive technologies that read `aria-describedby` but not the
|
|
662
|
+
* newer `aria-errormessage`.
|
|
663
|
+
*/
|
|
664
|
+
this.fieldDescribedBy = computed(() => [
|
|
665
|
+
this.describedBy() || undefined,
|
|
666
|
+
this.invalid() && this.labels().invalid ? this.statusId : undefined,
|
|
667
|
+
]
|
|
668
|
+
.filter(Boolean)
|
|
669
|
+
.join(" ") || null, /* @ts-ignore */
|
|
670
|
+
...(ngDevMode ? [{ debugName: "fieldDescribedBy" }] : /* istanbul ignore next */ []));
|
|
671
|
+
/** The text shown in the field. A pending edit wins until resolved. */
|
|
672
|
+
this.display = computed(() => {
|
|
673
|
+
const t = this.typed();
|
|
674
|
+
if (t !== null)
|
|
675
|
+
return t;
|
|
676
|
+
const formatter = this.formatValue() ?? ((v) => this.defaultFormat(v));
|
|
677
|
+
return formatter(this.value());
|
|
678
|
+
}, /* @ts-ignore */
|
|
679
|
+
...(ngDevMode ? [{ debugName: "display" }] : /* istanbul ignore next */ []));
|
|
680
|
+
/** The "March 2026" heading. */
|
|
681
|
+
/**
|
|
682
|
+
* `Intl.supportedValuesOf` is guarded rather than called bare: an empty
|
|
683
|
+
* select beats a throw at mount on an older embedded runtime that
|
|
684
|
+
* lacks the method entirely.
|
|
685
|
+
*/
|
|
686
|
+
this.zoneOptions = computed(() => {
|
|
687
|
+
if (this.timeZones() !== undefined)
|
|
688
|
+
return this.timeZones();
|
|
689
|
+
return typeof Intl.supportedValuesOf === "function"
|
|
690
|
+
? Intl.supportedValuesOf("timeZone")
|
|
691
|
+
: [];
|
|
692
|
+
}, /* @ts-ignore */
|
|
693
|
+
...(ngDevMode ? [{ debugName: "zoneOptions" }] : /* istanbul ignore next */ []));
|
|
694
|
+
this.periodText = computed(() => {
|
|
695
|
+
const viewYear = this.viewYear();
|
|
696
|
+
const viewMonth = this.viewMonth();
|
|
697
|
+
try {
|
|
698
|
+
return new Intl.DateTimeFormat(this.locale() || undefined, {
|
|
699
|
+
month: "long",
|
|
700
|
+
year: "numeric",
|
|
701
|
+
timeZone: "UTC",
|
|
702
|
+
}).format(new Date(Date.UTC(viewYear, viewMonth - 1, 1)));
|
|
703
|
+
}
|
|
704
|
+
catch {
|
|
705
|
+
return `${viewYear}-${pad(viewMonth)}`;
|
|
706
|
+
}
|
|
707
|
+
}, /* @ts-ignore */
|
|
708
|
+
...(ngDevMode ? [{ debugName: "periodText" }] : /* istanbul ignore next */ []));
|
|
709
|
+
/** Column headings, in this locale, starting on `weekStart`. */
|
|
710
|
+
this.weekdays = computed(() => {
|
|
711
|
+
const weekStart = this.weekStart();
|
|
712
|
+
const locale = this.locale() || undefined;
|
|
713
|
+
const out = [];
|
|
714
|
+
for (let i = 0; i < 7; i++) {
|
|
715
|
+
// 2021-08-01 was a Sunday, so this walks the week from whichever
|
|
716
|
+
// day the locale starts on.
|
|
717
|
+
const d = new Date(Date.UTC(2021, 7, 1 + ((weekStart + i) % 7)));
|
|
718
|
+
try {
|
|
719
|
+
out.push({
|
|
720
|
+
short: new Intl.DateTimeFormat(locale, {
|
|
721
|
+
weekday: "short",
|
|
722
|
+
timeZone: "UTC",
|
|
723
|
+
}).format(d),
|
|
724
|
+
long: new Intl.DateTimeFormat(locale, {
|
|
725
|
+
weekday: "long",
|
|
726
|
+
timeZone: "UTC",
|
|
727
|
+
}).format(d),
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
out.push({ short: "", long: "" });
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return out;
|
|
735
|
+
}, /* @ts-ignore */
|
|
736
|
+
...(ngDevMode ? [{ debugName: "weekdays" }] : /* istanbul ignore next */ []));
|
|
737
|
+
this.weeks = computed(() => monthMatrix(this.viewYear(), this.viewMonth(), this.weekStart()), /* @ts-ignore */
|
|
738
|
+
...(ngDevMode ? [{ debugName: "weeks" }] : /* istanbul ignore next */ []));
|
|
739
|
+
this.pendingHour = computed(() => parseIsoTime(this.pendingTime())?.hour ?? 0, /* @ts-ignore */
|
|
740
|
+
...(ngDevMode ? [{ debugName: "pendingHour" }] : /* istanbul ignore next */ []));
|
|
741
|
+
this.pendingMinute = computed(() => parseIsoTime(this.pendingTime())?.minute ?? 0, /* @ts-ignore */
|
|
742
|
+
...(ngDevMode ? [{ debugName: "pendingMinute" }] : /* istanbul ignore next */ []));
|
|
743
|
+
/**
|
|
744
|
+
* The hours offered.
|
|
745
|
+
*
|
|
746
|
+
* On a 12-hour clock this lists only the half of the day the meridiem
|
|
747
|
+
* select is currently on, so the two controls together name exactly one
|
|
748
|
+
* hour. Listing 1-12 once and letting the meridiem reinterpret it is
|
|
749
|
+
* the same thing, but it makes "12 AM" ambiguous at precisely the point
|
|
750
|
+
* where users already get it wrong.
|
|
751
|
+
*/
|
|
752
|
+
this.hourOptions = computed(() => {
|
|
753
|
+
const clock12 = this.clock12();
|
|
754
|
+
const pendingHour = this.pendingHour();
|
|
755
|
+
const out = [];
|
|
756
|
+
for (let h = 0; h < 24; h++) {
|
|
757
|
+
if (clock12 && h < 12 !== pendingHour < 12)
|
|
758
|
+
continue;
|
|
759
|
+
out.push({
|
|
760
|
+
value: h,
|
|
761
|
+
label: clock12 ? String(((h + 11) % 12) + 1) : pad(h),
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
return out;
|
|
765
|
+
}, /* @ts-ignore */
|
|
766
|
+
...(ngDevMode ? [{ debugName: "hourOptions" }] : /* istanbul ignore next */ []));
|
|
767
|
+
this.minuteOptions = computed(() => {
|
|
768
|
+
const step = Math.max(1, this.minuteStep());
|
|
769
|
+
const out = [];
|
|
770
|
+
for (let m = 0; m < 60; m += step)
|
|
771
|
+
out.push(m);
|
|
772
|
+
return out;
|
|
773
|
+
}, /* @ts-ignore */
|
|
774
|
+
...(ngDevMode ? [{ debugName: "minuteOptions" }] : /* istanbul ignore next */ []));
|
|
775
|
+
this.childContext = computed(() => {
|
|
776
|
+
const args = {
|
|
777
|
+
value: this.value(),
|
|
778
|
+
open: this.open(),
|
|
779
|
+
display: this.display(),
|
|
780
|
+
};
|
|
781
|
+
return { $implicit: args, ...args };
|
|
782
|
+
}, /* @ts-ignore */
|
|
783
|
+
...(ngDevMode ? [{ debugName: "childContext" }] : /* istanbul ignore next */ []));
|
|
784
|
+
this.initialised = false;
|
|
785
|
+
// Seeds the view month and the grid cursor from the committed value
|
|
786
|
+
// (or today) exactly once. Deferred to `effect()`, rather than read
|
|
787
|
+
// synchronously in the constructor, because signal inputs are not
|
|
788
|
+
// bound yet when the constructor runs — an Angular-forced difference
|
|
789
|
+
// from the Svelte original, which reads `value` synchronously at
|
|
790
|
+
// `$props()` time. `effect()` is this catalog's established seam for
|
|
791
|
+
// side effects driven by input signals; see catalog `AGENTS.md`.
|
|
792
|
+
effect(() => {
|
|
793
|
+
const committed = this.committed();
|
|
794
|
+
if (this.initialised)
|
|
795
|
+
return;
|
|
796
|
+
this.initialised = true;
|
|
797
|
+
const today = todayIso();
|
|
798
|
+
this.today.set(today);
|
|
799
|
+
const anchor = parseIsoDate(committed.date) ?? parseIsoDate(today);
|
|
800
|
+
if (anchor) {
|
|
801
|
+
this.viewYear.set(anchor.year);
|
|
802
|
+
this.viewMonth.set(anchor.month);
|
|
803
|
+
this.cursor.set(formatIsoDate(anchor));
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
// ---------------------------------------------------------------
|
|
808
|
+
// Template helpers
|
|
809
|
+
// ---------------------------------------------------------------
|
|
810
|
+
pad2(n) {
|
|
811
|
+
return pad(n);
|
|
812
|
+
}
|
|
813
|
+
isoWeekOf(isoDate) {
|
|
814
|
+
return isoWeek(isoDate);
|
|
815
|
+
}
|
|
816
|
+
/** Per-cell derived values for the grid template's `@let`. */
|
|
817
|
+
parseCell(isoDate) {
|
|
818
|
+
const parsed = parseIsoDate(isoDate);
|
|
819
|
+
return {
|
|
820
|
+
day: parsed ? String(parsed.day) : "",
|
|
821
|
+
outside: parsed ? parsed.month !== this.viewMonth() : false,
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
/** Accessible name for one day cell, e.g. "Sunday 1 March 2026". */
|
|
825
|
+
dayLabel(isoDate) {
|
|
826
|
+
const parsed = parseIsoDate(isoDate);
|
|
827
|
+
if (!parsed)
|
|
828
|
+
return isoDate;
|
|
829
|
+
try {
|
|
830
|
+
return new Intl.DateTimeFormat(this.locale() || undefined, {
|
|
831
|
+
weekday: "long",
|
|
832
|
+
day: "numeric",
|
|
833
|
+
month: "long",
|
|
834
|
+
year: "numeric",
|
|
835
|
+
timeZone: "UTC",
|
|
836
|
+
}).format(new Date(Date.UTC(parsed.year, parsed.month - 1, parsed.day)));
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
return isoDate;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
/** The locale's own AM / PM strings, so neither is hardcoded. */
|
|
843
|
+
dayPeriodName(pm) {
|
|
844
|
+
try {
|
|
845
|
+
const parts = new Intl.DateTimeFormat(this.locale() || undefined, {
|
|
846
|
+
hour: "numeric",
|
|
847
|
+
hour12: true,
|
|
848
|
+
timeZone: "UTC",
|
|
849
|
+
}).formatToParts(new Date(Date.UTC(2021, 0, 1, pm ? 13 : 1)));
|
|
850
|
+
const found = parts.find((p) => p.type === "dayPeriod")?.value;
|
|
851
|
+
if (found)
|
|
852
|
+
return found;
|
|
853
|
+
}
|
|
854
|
+
catch {
|
|
855
|
+
// Fall through.
|
|
856
|
+
}
|
|
857
|
+
return pm ? "PM" : "AM";
|
|
858
|
+
}
|
|
859
|
+
formatTimeForDisplay(isoTime) {
|
|
860
|
+
const parsed = parseIsoTime(isoTime);
|
|
861
|
+
if (!parsed)
|
|
862
|
+
return isoTime;
|
|
863
|
+
try {
|
|
864
|
+
return new Intl.DateTimeFormat(this.locale() || undefined, {
|
|
865
|
+
hour: "2-digit",
|
|
866
|
+
minute: "2-digit",
|
|
867
|
+
hour12: this.clock12(),
|
|
868
|
+
timeZone: "UTC",
|
|
869
|
+
}).format(new Date(Date.UTC(2021, 0, 1, parsed.hour, parsed.minute)));
|
|
870
|
+
}
|
|
871
|
+
catch {
|
|
872
|
+
return isoTime;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
/** Render an ISO value the way this locale writes it. */
|
|
876
|
+
defaultFormat(isoValue) {
|
|
877
|
+
if (!isoValue)
|
|
878
|
+
return "";
|
|
879
|
+
const { date, time } = splitValue(isoValue, this.mode());
|
|
880
|
+
const chunks = [];
|
|
881
|
+
const parsed = date ? parseIsoDate(date) : null;
|
|
882
|
+
if (parsed) {
|
|
883
|
+
try {
|
|
884
|
+
chunks.push(new Intl.DateTimeFormat(this.locale() || undefined, {
|
|
885
|
+
day: "2-digit",
|
|
886
|
+
month: "short",
|
|
887
|
+
year: "numeric",
|
|
888
|
+
timeZone: "UTC",
|
|
889
|
+
}).format(new Date(Date.UTC(parsed.year, parsed.month - 1, parsed.day))));
|
|
890
|
+
}
|
|
891
|
+
catch {
|
|
892
|
+
chunks.push(date);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (time)
|
|
896
|
+
chunks.push(this.formatTimeForDisplay(time));
|
|
897
|
+
return chunks.join(" ");
|
|
898
|
+
}
|
|
899
|
+
// ---------------------------------------------------------------
|
|
900
|
+
// Selectability
|
|
901
|
+
// ---------------------------------------------------------------
|
|
902
|
+
dayDisabled(isoDate) {
|
|
903
|
+
if (!withinRange(isoDate, this.min() || undefined, this.max() || undefined)) {
|
|
904
|
+
return true;
|
|
905
|
+
}
|
|
906
|
+
return this.isDateDisabled()?.(isoDate) === true;
|
|
907
|
+
}
|
|
908
|
+
/** The nearest selectable day to `isoDate`, searching outwards. */
|
|
909
|
+
nearestSelectable(isoDate) {
|
|
910
|
+
if (!this.dayDisabled(isoDate))
|
|
911
|
+
return isoDate;
|
|
912
|
+
// Bounded: a year either way is far beyond any real min/max window,
|
|
913
|
+
// and an unbounded search would hang on a predicate that disables
|
|
914
|
+
// everything.
|
|
915
|
+
for (let delta = 1; delta <= 366; delta++) {
|
|
916
|
+
const after = addDays(isoDate, delta);
|
|
917
|
+
if (!this.dayDisabled(after))
|
|
918
|
+
return after;
|
|
919
|
+
const before = addDays(isoDate, -delta);
|
|
920
|
+
if (!this.dayDisabled(before))
|
|
921
|
+
return before;
|
|
922
|
+
}
|
|
923
|
+
return isoDate;
|
|
924
|
+
}
|
|
925
|
+
// ---------------------------------------------------------------
|
|
926
|
+
// Open / close / commit
|
|
927
|
+
// ---------------------------------------------------------------
|
|
928
|
+
openDialog() {
|
|
929
|
+
if (this.disabled() || this.readOnly())
|
|
930
|
+
return;
|
|
931
|
+
// Remember what opened the dialog — the field on Alt+ArrowDown, the
|
|
932
|
+
// trigger button on a click — so closeDialog can put focus back
|
|
933
|
+
// there, per the APG dialog rule.
|
|
934
|
+
const active = document.activeElement;
|
|
935
|
+
this.openerEl =
|
|
936
|
+
active instanceof HTMLElement &&
|
|
937
|
+
this.rootRef().nativeElement.contains(active)
|
|
938
|
+
? active
|
|
939
|
+
: (this.buttonRef().nativeElement ?? null);
|
|
940
|
+
const today = todayIso();
|
|
941
|
+
this.today.set(today);
|
|
942
|
+
const pendingDate = this.committed().date || this.nearestSelectable(today);
|
|
943
|
+
this.pendingDate.set(pendingDate);
|
|
944
|
+
this.pendingTime.set(this.committed().time || this.defaultTime());
|
|
945
|
+
this.cursor.set(pendingDate);
|
|
946
|
+
const anchor = parseIsoDate(pendingDate) ?? parseIsoDate(today);
|
|
947
|
+
if (anchor) {
|
|
948
|
+
this.viewYear.set(anchor.year);
|
|
949
|
+
this.viewMonth.set(anchor.month);
|
|
950
|
+
}
|
|
951
|
+
this.open.set(true);
|
|
952
|
+
// Render first, focus second: until change detection runs, the
|
|
953
|
+
// dialog still carries `hidden`, and focusing into a hidden subtree
|
|
954
|
+
// is a no-op in a real browser.
|
|
955
|
+
this.cdr.detectChanges();
|
|
956
|
+
this.focusInitial();
|
|
957
|
+
}
|
|
958
|
+
/** Where an unset time starts: now, snapped down to the step. */
|
|
959
|
+
defaultTime() {
|
|
960
|
+
if (!this.usesTime())
|
|
961
|
+
return "";
|
|
962
|
+
const now = new Date();
|
|
963
|
+
const step = Math.max(1, this.minuteStep());
|
|
964
|
+
return formatIsoTime({
|
|
965
|
+
hour: now.getHours(),
|
|
966
|
+
minute: Math.floor(now.getMinutes() / step) * step,
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
/** Focus the grid cursor, or the first control when there is no grid. */
|
|
970
|
+
focusInitial() {
|
|
971
|
+
if (this.usesDate()) {
|
|
972
|
+
this.focusCursor();
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
this.focusables()[0]?.focus?.();
|
|
976
|
+
}
|
|
977
|
+
closeDialog(refocus = true) {
|
|
978
|
+
if (!this.open())
|
|
979
|
+
return;
|
|
980
|
+
this.open.set(false);
|
|
981
|
+
if (refocus) {
|
|
982
|
+
const target = this.openerEl ?? this.buttonRef().nativeElement;
|
|
983
|
+
queueMicrotask(() => target?.focus?.());
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
/** Commit the pending selection to `value` and notify. */
|
|
987
|
+
commit() {
|
|
988
|
+
const next = joinValue(this.pendingDate(), this.pendingTime(), this.mode());
|
|
989
|
+
// An incomplete datetime is not committed. Half a timestamp is not a
|
|
990
|
+
// smaller truth; it is a different one.
|
|
991
|
+
if (!next)
|
|
992
|
+
return;
|
|
993
|
+
this.typed.set(null);
|
|
994
|
+
this.invalid.set(false);
|
|
995
|
+
if (next !== this.value()) {
|
|
996
|
+
this.value.set(next);
|
|
997
|
+
this.change.emit(next);
|
|
998
|
+
}
|
|
999
|
+
this.closeDialog();
|
|
1000
|
+
}
|
|
1001
|
+
clear() {
|
|
1002
|
+
this.typed.set(null);
|
|
1003
|
+
this.invalid.set(false);
|
|
1004
|
+
if (this.value() !== "") {
|
|
1005
|
+
this.value.set("");
|
|
1006
|
+
this.change.emit("");
|
|
1007
|
+
}
|
|
1008
|
+
this.closeDialog();
|
|
1009
|
+
}
|
|
1010
|
+
// ---------------------------------------------------------------
|
|
1011
|
+
// Grid navigation
|
|
1012
|
+
// ---------------------------------------------------------------
|
|
1013
|
+
/**
|
|
1014
|
+
* Move the cursor, paging the view when the target is off-screen.
|
|
1015
|
+
*
|
|
1016
|
+
* Disabled days are still reachable — the cursor lands on them and the
|
|
1017
|
+
* button is `aria-disabled` (so it takes real focus and announces), so
|
|
1018
|
+
* arrowing across a blocked range works. What is refused is leaving
|
|
1019
|
+
* the min/max window entirely, because there is nothing out there to
|
|
1020
|
+
* navigate to.
|
|
1021
|
+
*/
|
|
1022
|
+
moveCursor(nextIso) {
|
|
1023
|
+
if (!withinRange(nextIso, this.min() || undefined, this.max() || undefined)) {
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
this.cursor.set(nextIso);
|
|
1027
|
+
const parsed = parseIsoDate(nextIso);
|
|
1028
|
+
if (parsed &&
|
|
1029
|
+
(parsed.year !== this.viewYear() || parsed.month !== this.viewMonth())) {
|
|
1030
|
+
this.viewYear.set(parsed.year);
|
|
1031
|
+
this.viewMonth.set(parsed.month);
|
|
1032
|
+
}
|
|
1033
|
+
// Synchronous render-then-focus: when the cursor crossed into a new
|
|
1034
|
+
// month, the cell it needs does not exist until change detection has
|
|
1035
|
+
// run — see `cdr`.
|
|
1036
|
+
this.cdr.detectChanges();
|
|
1037
|
+
this.focusCursor();
|
|
1038
|
+
}
|
|
1039
|
+
focusCursor() {
|
|
1040
|
+
const dialogEl = this.dialogRef()?.nativeElement;
|
|
1041
|
+
const cursor = this.cursor();
|
|
1042
|
+
if (!dialogEl || !cursor)
|
|
1043
|
+
return;
|
|
1044
|
+
// An attribute selector, not `#id`: the id is not on this element, and
|
|
1045
|
+
// a value we generated as `YYYY-MM-DD` needs no escaping.
|
|
1046
|
+
const el = dialogEl.querySelector(`[data-date="${cursor}"]`);
|
|
1047
|
+
// Guard the METHODS, not only the element. jsdom implements no
|
|
1048
|
+
// `scrollIntoView`, and an unguarded call throws from inside a
|
|
1049
|
+
// keydown handler — where it stays invisible to a green suite.
|
|
1050
|
+
el?.focus?.();
|
|
1051
|
+
el?.scrollIntoView?.({ block: "nearest" });
|
|
1052
|
+
}
|
|
1053
|
+
selectDay(isoDate) {
|
|
1054
|
+
if (this.dayDisabled(isoDate))
|
|
1055
|
+
return;
|
|
1056
|
+
this.pendingDate.set(isoDate);
|
|
1057
|
+
this.cursor.set(isoDate);
|
|
1058
|
+
if (this.commitOnDay())
|
|
1059
|
+
this.commit();
|
|
1060
|
+
}
|
|
1061
|
+
shiftMonth(delta) {
|
|
1062
|
+
const anchor = formatIsoDate({
|
|
1063
|
+
year: this.viewYear(),
|
|
1064
|
+
month: this.viewMonth(),
|
|
1065
|
+
day: 1,
|
|
1066
|
+
});
|
|
1067
|
+
const next = parseIsoDate(addMonths(anchor, delta));
|
|
1068
|
+
if (!next)
|
|
1069
|
+
return;
|
|
1070
|
+
// Refocus the cursor only when focus is already in the grid: grid
|
|
1071
|
+
// paging (PageUp/PageDown, or a browser that focused nothing on
|
|
1072
|
+
// click) must carry focus, or it dies with the unrendered cell —
|
|
1073
|
+
// but a header button must keep focus, or its user is yanked into
|
|
1074
|
+
// the grid after one activation and cannot page twice.
|
|
1075
|
+
const hadGridFocus = this.gridRef()?.nativeElement.contains(document.activeElement) === true;
|
|
1076
|
+
this.viewYear.set(next.year);
|
|
1077
|
+
this.viewMonth.set(next.month);
|
|
1078
|
+
// Carry the cursor into the new month rather than leaving the roving
|
|
1079
|
+
// tabindex on a cell that is no longer rendered.
|
|
1080
|
+
const c = parseIsoDate(this.cursor());
|
|
1081
|
+
if (c) {
|
|
1082
|
+
this.cursor.set(formatIsoDate({
|
|
1083
|
+
year: next.year,
|
|
1084
|
+
month: next.month,
|
|
1085
|
+
day: Math.min(c.day, daysInMonth(next.year, next.month)),
|
|
1086
|
+
}));
|
|
1087
|
+
if (hadGridFocus) {
|
|
1088
|
+
// Render-then-focus, synchronously — the target cell does not
|
|
1089
|
+
// exist until change detection has run. See `cdr`.
|
|
1090
|
+
this.cdr.detectChanges();
|
|
1091
|
+
this.focusCursor();
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
shiftYear(delta) {
|
|
1096
|
+
this.shiftMonth(delta * 12);
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Week/day steps are the fine end of the header: unlike month/year,
|
|
1100
|
+
* which move the GRID and merely carry the cursor, these move the
|
|
1101
|
+
* pending day itself by ±7 / ±1 civil days and page the grid only
|
|
1102
|
+
* when the new day leaves the shown month. A step off the min/max
|
|
1103
|
+
* window is refused outright; a step onto a vetoed day moves the
|
|
1104
|
+
* cursor — vetoed days are reachable, as with the arrow keys — but
|
|
1105
|
+
* leaves the pending selection where it was. No commit even under
|
|
1106
|
+
* `confirmOnSelect`.
|
|
1107
|
+
*/
|
|
1108
|
+
shiftDays(delta) {
|
|
1109
|
+
const from = parseIsoDate(this.cursor()) ? this.cursor() : this.pendingDate();
|
|
1110
|
+
if (!from)
|
|
1111
|
+
return;
|
|
1112
|
+
const next = addDays(from, delta);
|
|
1113
|
+
if (!withinRange(next, this.min() || undefined, this.max() || undefined)) {
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
const hadGridFocus = this.gridRef()?.nativeElement.contains(document.activeElement) === true;
|
|
1117
|
+
const parsed = parseIsoDate(next);
|
|
1118
|
+
if (parsed &&
|
|
1119
|
+
(parsed.year !== this.viewYear() || parsed.month !== this.viewMonth())) {
|
|
1120
|
+
this.viewYear.set(parsed.year);
|
|
1121
|
+
this.viewMonth.set(parsed.month);
|
|
1122
|
+
}
|
|
1123
|
+
this.cursor.set(next);
|
|
1124
|
+
if (!this.dayDisabled(next))
|
|
1125
|
+
this.pendingDate.set(next);
|
|
1126
|
+
if (hadGridFocus) {
|
|
1127
|
+
this.cdr.detectChanges();
|
|
1128
|
+
this.focusCursor();
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
onTimeZoneSelect(event) {
|
|
1132
|
+
this.timeZone.set(event.target.value);
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* A plain index expression (`timeZoneLabels()[zone] ?? zone`) trips
|
|
1136
|
+
* NG8102: without `noUncheckedIndexedAccess`, TypeScript types a
|
|
1137
|
+
* `Record<string, string>` index as always `string`, so the compiler
|
|
1138
|
+
* sees the `??` as dead code even though a real, unlisted zone id is
|
|
1139
|
+
* exactly the case it exists for. The cast documents that the index
|
|
1140
|
+
* genuinely can miss.
|
|
1141
|
+
*/
|
|
1142
|
+
zoneLabel(zone) {
|
|
1143
|
+
return (this.timeZoneLabels()[zone] ?? zone);
|
|
1144
|
+
}
|
|
1145
|
+
onGridKeydown(event) {
|
|
1146
|
+
switch (event.key) {
|
|
1147
|
+
case "ArrowLeft":
|
|
1148
|
+
event.preventDefault();
|
|
1149
|
+
this.moveCursor(addDays(this.cursor(), -1));
|
|
1150
|
+
break;
|
|
1151
|
+
case "ArrowRight":
|
|
1152
|
+
event.preventDefault();
|
|
1153
|
+
this.moveCursor(addDays(this.cursor(), 1));
|
|
1154
|
+
break;
|
|
1155
|
+
case "ArrowUp":
|
|
1156
|
+
event.preventDefault();
|
|
1157
|
+
this.moveCursor(addDays(this.cursor(), -7));
|
|
1158
|
+
break;
|
|
1159
|
+
case "ArrowDown":
|
|
1160
|
+
event.preventDefault();
|
|
1161
|
+
this.moveCursor(addDays(this.cursor(), 7));
|
|
1162
|
+
break;
|
|
1163
|
+
case "Home": {
|
|
1164
|
+
event.preventDefault();
|
|
1165
|
+
const offset = (weekdayOf(this.cursor()) - this.weekStart() + 7) % 7;
|
|
1166
|
+
this.moveCursor(addDays(this.cursor(), -offset));
|
|
1167
|
+
break;
|
|
1168
|
+
}
|
|
1169
|
+
case "End": {
|
|
1170
|
+
event.preventDefault();
|
|
1171
|
+
const offset = (weekdayOf(this.cursor()) - this.weekStart() + 7) % 7;
|
|
1172
|
+
this.moveCursor(addDays(this.cursor(), 6 - offset));
|
|
1173
|
+
break;
|
|
1174
|
+
}
|
|
1175
|
+
case "PageUp":
|
|
1176
|
+
event.preventDefault();
|
|
1177
|
+
if (event.shiftKey)
|
|
1178
|
+
this.shiftYear(-1);
|
|
1179
|
+
else
|
|
1180
|
+
this.shiftMonth(-1);
|
|
1181
|
+
break;
|
|
1182
|
+
case "PageDown":
|
|
1183
|
+
event.preventDefault();
|
|
1184
|
+
if (event.shiftKey)
|
|
1185
|
+
this.shiftYear(1);
|
|
1186
|
+
else
|
|
1187
|
+
this.shiftMonth(1);
|
|
1188
|
+
break;
|
|
1189
|
+
case "Enter":
|
|
1190
|
+
case " ":
|
|
1191
|
+
event.preventDefault();
|
|
1192
|
+
this.selectDay(this.cursor());
|
|
1193
|
+
break;
|
|
1194
|
+
default:
|
|
1195
|
+
break;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
// ---------------------------------------------------------------
|
|
1199
|
+
// Dialog keys and the focus trap
|
|
1200
|
+
// ---------------------------------------------------------------
|
|
1201
|
+
/** Everything tabbable inside the dialog, in DOM order. */
|
|
1202
|
+
focusables() {
|
|
1203
|
+
const dialogEl = this.dialogRef()?.nativeElement;
|
|
1204
|
+
if (!dialogEl)
|
|
1205
|
+
return [];
|
|
1206
|
+
return Array.from(dialogEl.querySelectorAll('button:not([disabled]):not([tabindex="-1"]), select:not([disabled])'));
|
|
1207
|
+
}
|
|
1208
|
+
onDialogKeydown(event) {
|
|
1209
|
+
if (event.key === "Escape") {
|
|
1210
|
+
event.preventDefault();
|
|
1211
|
+
// Escape discards: `value` is untouched, so the field still shows
|
|
1212
|
+
// whatever was committed before the dialog opened.
|
|
1213
|
+
this.closeDialog();
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
if (event.key !== "Tab")
|
|
1217
|
+
return;
|
|
1218
|
+
// The trap. `aria-modal="true"` is a promise that focus cannot reach
|
|
1219
|
+
// the page behind the dialog, and it is a promise the browser does
|
|
1220
|
+
// not keep for us: an untrapped aria-modal dialog tells a screen
|
|
1221
|
+
// reader the rest of the page is inert while Tab quietly walks into
|
|
1222
|
+
// it.
|
|
1223
|
+
const all = this.focusables();
|
|
1224
|
+
if (all.length === 0)
|
|
1225
|
+
return;
|
|
1226
|
+
const first = all[0];
|
|
1227
|
+
const last = all[all.length - 1];
|
|
1228
|
+
const active = document.activeElement;
|
|
1229
|
+
const dialogEl = this.dialogRef()?.nativeElement;
|
|
1230
|
+
if (event.shiftKey && (active === first || !dialogEl?.contains(active))) {
|
|
1231
|
+
event.preventDefault();
|
|
1232
|
+
last.focus?.();
|
|
1233
|
+
}
|
|
1234
|
+
else if (!event.shiftKey && active === last) {
|
|
1235
|
+
event.preventDefault();
|
|
1236
|
+
first.focus?.();
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
// ---------------------------------------------------------------
|
|
1240
|
+
// Text field
|
|
1241
|
+
// ---------------------------------------------------------------
|
|
1242
|
+
/** Default text parsing, per mode. */
|
|
1243
|
+
parseTypedForMode(text) {
|
|
1244
|
+
if (this.mode() === "time")
|
|
1245
|
+
return parseTimeInput(text);
|
|
1246
|
+
if (this.mode() === "date")
|
|
1247
|
+
return parseDateInput(text, this.locale() || undefined);
|
|
1248
|
+
// datetime: split on the last whitespace run or a literal T, and
|
|
1249
|
+
// require both halves — see `commit` on why half is refused.
|
|
1250
|
+
const m = /^(.*?)[T\s]+([^\sT]+)$/.exec(text.trim());
|
|
1251
|
+
if (!m)
|
|
1252
|
+
return null;
|
|
1253
|
+
const date = parseDateInput(m[1], this.locale() || undefined);
|
|
1254
|
+
const time = parseTimeInput(m[2]);
|
|
1255
|
+
return date && time ? `${date}T${time}` : null;
|
|
1256
|
+
}
|
|
1257
|
+
/** Resolve typed text on blur or Enter. */
|
|
1258
|
+
resolveTyped() {
|
|
1259
|
+
const text = this.typed();
|
|
1260
|
+
if (text === null)
|
|
1261
|
+
return;
|
|
1262
|
+
if (!text.trim()) {
|
|
1263
|
+
this.typed.set(null);
|
|
1264
|
+
this.invalid.set(false);
|
|
1265
|
+
if (this.value() !== "") {
|
|
1266
|
+
this.value.set("");
|
|
1267
|
+
this.change.emit("");
|
|
1268
|
+
}
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
const parser = this.parseInput();
|
|
1272
|
+
const parsed = parser ? parser(text) : this.parseTypedForMode(text);
|
|
1273
|
+
if (!parsed) {
|
|
1274
|
+
this.invalid.set(true);
|
|
1275
|
+
this.invalidInput.emit(text);
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
const { date } = splitValue(parsed, this.mode());
|
|
1279
|
+
if (this.usesDate() && date && this.dayDisabled(date)) {
|
|
1280
|
+
// Parseable but out of bounds. Same outcome as unparseable: the
|
|
1281
|
+
// text stays put and the field is marked invalid, rather than being
|
|
1282
|
+
// silently snapped to some nearby legal date the user never typed.
|
|
1283
|
+
this.invalid.set(true);
|
|
1284
|
+
this.invalidInput.emit(text);
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
this.typed.set(null);
|
|
1288
|
+
this.invalid.set(false);
|
|
1289
|
+
if (parsed !== this.value()) {
|
|
1290
|
+
this.value.set(parsed);
|
|
1291
|
+
this.change.emit(parsed);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
onFieldKeydown(event) {
|
|
1295
|
+
if (event.key === "Enter") {
|
|
1296
|
+
event.preventDefault();
|
|
1297
|
+
this.resolveTyped();
|
|
1298
|
+
}
|
|
1299
|
+
else if (event.key === "ArrowDown" && event.altKey) {
|
|
1300
|
+
// The platform convention for "open the picker" from a field,
|
|
1301
|
+
// matching <input type="date"> in every major browser.
|
|
1302
|
+
event.preventDefault();
|
|
1303
|
+
this.openDialog();
|
|
1304
|
+
}
|
|
1305
|
+
else if (event.key === "Escape" && this.typed() !== null) {
|
|
1306
|
+
// Discard the pending edit and show the committed value again —
|
|
1307
|
+
// the same contract Escape has inside the dialog. Stops
|
|
1308
|
+
// propagating so a surrounding dialog does not also close on
|
|
1309
|
+
// what was, to the user, a text-editing keystroke. When no edit
|
|
1310
|
+
// is pending the key is left alone.
|
|
1311
|
+
event.preventDefault();
|
|
1312
|
+
event.stopPropagation();
|
|
1313
|
+
this.typed.set(null);
|
|
1314
|
+
this.invalid.set(false);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
// ---------------------------------------------------------------
|
|
1318
|
+
// Time selects
|
|
1319
|
+
// ---------------------------------------------------------------
|
|
1320
|
+
setHour(hour) {
|
|
1321
|
+
this.pendingTime.set(formatIsoTime({ hour, minute: this.pendingMinute() }));
|
|
1322
|
+
}
|
|
1323
|
+
setMinute(minute) {
|
|
1324
|
+
this.pendingTime.set(formatIsoTime({ hour: this.pendingHour(), minute }));
|
|
1325
|
+
}
|
|
1326
|
+
/** Cross between AM and PM without changing the minute of the hour. */
|
|
1327
|
+
setMeridiem(pm) {
|
|
1328
|
+
this.setHour((this.pendingHour() % 12) + (pm ? 12 : 0));
|
|
1329
|
+
}
|
|
1330
|
+
// ---------------------------------------------------------------
|
|
1331
|
+
// Shortcuts
|
|
1332
|
+
// ---------------------------------------------------------------
|
|
1333
|
+
applyShortcut(shortcut) {
|
|
1334
|
+
const base = todayIso();
|
|
1335
|
+
let target = shortcut.date ?? base;
|
|
1336
|
+
if (shortcut.days !== undefined)
|
|
1337
|
+
target = addDays(base, shortcut.days);
|
|
1338
|
+
else if (shortcut.months !== undefined) {
|
|
1339
|
+
target = addMonths(base, shortcut.months);
|
|
1340
|
+
}
|
|
1341
|
+
// A shortcut to a blocked date does nothing rather than landing
|
|
1342
|
+
// somewhere near it: "+4 weeks" that quietly means "+27 days" is a
|
|
1343
|
+
// booking error waiting to happen.
|
|
1344
|
+
if (this.dayDisabled(target))
|
|
1345
|
+
return;
|
|
1346
|
+
this.pendingDate.set(target);
|
|
1347
|
+
this.cursor.set(target);
|
|
1348
|
+
const parsed = parseIsoDate(target);
|
|
1349
|
+
if (parsed) {
|
|
1350
|
+
this.viewYear.set(parsed.year);
|
|
1351
|
+
this.viewMonth.set(parsed.month);
|
|
1352
|
+
}
|
|
1353
|
+
this.shortcut.emit({ id: shortcut.id, isoDate: target });
|
|
1354
|
+
if (this.commitOnDay())
|
|
1355
|
+
this.commit();
|
|
1356
|
+
else {
|
|
1357
|
+
// Render-then-focus: the shortcut may have paged to a month whose
|
|
1358
|
+
// cells do not exist until change detection has run. See `cdr`.
|
|
1359
|
+
this.cdr.detectChanges();
|
|
1360
|
+
this.focusCursor();
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
// ---------------------------------------------------------------
|
|
1364
|
+
// Dismissal
|
|
1365
|
+
// ---------------------------------------------------------------
|
|
1366
|
+
onDocumentClick(event) {
|
|
1367
|
+
if (!this.open())
|
|
1368
|
+
return;
|
|
1369
|
+
const target = event.target;
|
|
1370
|
+
if (!target)
|
|
1371
|
+
return;
|
|
1372
|
+
// Anything outside the dialog closes it — including the component's
|
|
1373
|
+
// own text field. The dialog says aria-modal="true", and a modal
|
|
1374
|
+
// that stays open while the user edits the field behind it is
|
|
1375
|
+
// telling assistive technology one thing and doing another. The
|
|
1376
|
+
// trigger button is exempt because its own handler already toggles.
|
|
1377
|
+
if (this.dialogRef().nativeElement.contains(target) ||
|
|
1378
|
+
this.buttonRef().nativeElement.contains(target)) {
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
this.closeDialog(false);
|
|
1382
|
+
}
|
|
1383
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.4", ngImport: i0, type: DateTimePicker, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
1384
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.4", type: DateTimePicker, isStandalone: true, selector: "lily-date-time-picker", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: true, transformFunction: null }, labels: { classPropertyName: "labels", publicName: "labels", isSignal: true, isRequired: true, transformFunction: null }, mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, locale: { classPropertyName: "locale", publicName: "locale", isSignal: true, isRequired: false, transformFunction: null }, min: { classPropertyName: "min", publicName: "min", isSignal: true, isRequired: false, transformFunction: null }, max: { classPropertyName: "max", publicName: "max", isSignal: true, isRequired: false, transformFunction: null }, isDateDisabled: { classPropertyName: "isDateDisabled", publicName: "isDateDisabled", isSignal: true, isRequired: false, transformFunction: null }, firstDayOfWeek: { classPropertyName: "firstDayOfWeek", publicName: "firstDayOfWeek", isSignal: true, isRequired: false, transformFunction: null }, minuteStep: { classPropertyName: "minuteStep", publicName: "minuteStep", isSignal: true, isRequired: false, transformFunction: null }, hour12: { classPropertyName: "hour12", publicName: "hour12", isSignal: true, isRequired: false, transformFunction: null }, showWeekNumbers: { classPropertyName: "showWeekNumbers", publicName: "showWeekNumbers", isSignal: true, isRequired: false, transformFunction: null }, shortcuts: { classPropertyName: "shortcuts", publicName: "shortcuts", isSignal: true, isRequired: false, transformFunction: null }, confirmOnSelect: { classPropertyName: "confirmOnSelect", publicName: "confirmOnSelect", isSignal: true, isRequired: false, transformFunction: null }, name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: false, transformFunction: null }, timeZone: { classPropertyName: "timeZone", publicName: "timeZone", isSignal: true, isRequired: false, transformFunction: null }, timeZones: { classPropertyName: "timeZones", publicName: "timeZones", isSignal: true, isRequired: false, transformFunction: null }, timeZoneLabels: { classPropertyName: "timeZoneLabels", publicName: "timeZoneLabels", isSignal: true, isRequired: false, transformFunction: null }, inputId: { classPropertyName: "inputId", publicName: "inputId", isSignal: true, isRequired: false, transformFunction: null }, describedBy: { classPropertyName: "describedBy", publicName: "describedBy", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, readOnly: { classPropertyName: "readOnly", publicName: "readOnly", isSignal: true, isRequired: false, transformFunction: null }, required: { classPropertyName: "required", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, formatValue: { classPropertyName: "formatValue", publicName: "formatValue", isSignal: true, isRequired: false, transformFunction: null }, parseInput: { classPropertyName: "parseInput", publicName: "parseInput", isSignal: true, isRequired: false, transformFunction: null }, className: { classPropertyName: "className", publicName: "className", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange", timeZone: "timeZoneChange", change: "change", shortcut: "shortcut", invalidInput: "invalidInput" }, host: { listeners: { "document:click": "onDocumentClick($event)" } }, queries: [{ propertyName: "iconTemplate", first: true, predicate: TemplateRef, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "rootRef", first: true, predicate: ["rootEl"], descendants: true, isSignal: true }, { propertyName: "buttonRef", first: true, predicate: ["buttonEl"], descendants: true, isSignal: true }, { propertyName: "dialogRef", first: true, predicate: ["dialogEl"], descendants: true, isSignal: true }, { propertyName: "gridRef", first: true, predicate: ["gridEl"], descendants: true, isSignal: true }], ngImport: i0, template: `
|
|
1385
|
+
<div
|
|
1386
|
+
#rootEl
|
|
1387
|
+
class="date-time-picker {{ className() }}"
|
|
1388
|
+
[attr.data-mode]="mode()"
|
|
1389
|
+
[attr.data-time-zone]="timeZone() || null"
|
|
1390
|
+
>
|
|
1391
|
+
<input type="hidden" [name]="name()" [value]="value()" />
|
|
1392
|
+
@if (labels().timeZone) {
|
|
1393
|
+
<input
|
|
1394
|
+
type="hidden"
|
|
1395
|
+
[name]="name() + '-time-zone'"
|
|
1396
|
+
[value]="timeZone()"
|
|
1397
|
+
/>
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
<div class="date-time-picker-field">
|
|
1401
|
+
<input
|
|
1402
|
+
class="date-time-picker-input"
|
|
1403
|
+
[id]="fieldId()"
|
|
1404
|
+
type="text"
|
|
1405
|
+
autocomplete="off"
|
|
1406
|
+
[value]="display()"
|
|
1407
|
+
[attr.placeholder]="placeholder() || null"
|
|
1408
|
+
[disabled]="disabled()"
|
|
1409
|
+
[readOnly]="readOnly()"
|
|
1410
|
+
[required]="required()"
|
|
1411
|
+
[attr.aria-describedby]="fieldDescribedBy()"
|
|
1412
|
+
[attr.aria-invalid]="invalid() ? 'true' : null"
|
|
1413
|
+
[attr.aria-errormessage]="
|
|
1414
|
+
invalid() && labels().invalid ? statusId : null
|
|
1415
|
+
"
|
|
1416
|
+
(input)="typed.set($any($event.target).value)"
|
|
1417
|
+
(blur)="resolveTyped()"
|
|
1418
|
+
(keydown)="onFieldKeydown($event)"
|
|
1419
|
+
/>
|
|
1420
|
+
|
|
1421
|
+
<button
|
|
1422
|
+
#buttonEl
|
|
1423
|
+
type="button"
|
|
1424
|
+
class="date-time-picker-button"
|
|
1425
|
+
[attr.aria-label]="label()"
|
|
1426
|
+
aria-haspopup="dialog"
|
|
1427
|
+
[attr.aria-expanded]="open()"
|
|
1428
|
+
[attr.aria-controls]="dialogId"
|
|
1429
|
+
[disabled]="disabled() || readOnly()"
|
|
1430
|
+
(click)="open() ? closeDialog() : openDialog()"
|
|
1431
|
+
>
|
|
1432
|
+
@if (iconTemplate(); as tpl) {
|
|
1433
|
+
<ng-container
|
|
1434
|
+
[ngTemplateOutlet]="tpl"
|
|
1435
|
+
[ngTemplateOutletContext]="childContext()"
|
|
1436
|
+
/>
|
|
1437
|
+
} @else {
|
|
1438
|
+
<span class="date-time-picker-icon" aria-hidden="true">{{
|
|
1439
|
+
glyph
|
|
1440
|
+
}}</span>
|
|
1441
|
+
}
|
|
1442
|
+
</button>
|
|
1443
|
+
</div>
|
|
1444
|
+
|
|
1445
|
+
@if (labels().invalid) {
|
|
1446
|
+
<!-- Present in the DOM before it has content: a live region that
|
|
1447
|
+
appears at the same moment as its message is routinely not
|
|
1448
|
+
announced at all. Empty while the field is valid. -->
|
|
1449
|
+
<span class="date-time-picker-status" [id]="statusId" role="status">{{
|
|
1450
|
+
invalid() ? labels().invalid : ""
|
|
1451
|
+
}}</span>
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
<div
|
|
1455
|
+
#dialogEl
|
|
1456
|
+
class="date-time-picker-dialog"
|
|
1457
|
+
[id]="dialogId"
|
|
1458
|
+
role="dialog"
|
|
1459
|
+
aria-modal="true"
|
|
1460
|
+
[attr.aria-label]="label()"
|
|
1461
|
+
[attr.aria-describedby]="labels().instructions ? instructionsId : null"
|
|
1462
|
+
tabindex="-1"
|
|
1463
|
+
[attr.hidden]="open() ? null : ''"
|
|
1464
|
+
(keydown)="onDialogKeydown($event)"
|
|
1465
|
+
>
|
|
1466
|
+
@if (labels().instructions) {
|
|
1467
|
+
<!-- Spoken once when the dialog takes focus, via the dialog's
|
|
1468
|
+
aria-describedby. Visible by default; a consumer who wants
|
|
1469
|
+
it screen-reader-only hides it with their own CSS. -->
|
|
1470
|
+
<p class="date-time-picker-instructions" [id]="instructionsId">
|
|
1471
|
+
{{ labels().instructions }}
|
|
1472
|
+
</p>
|
|
1473
|
+
}
|
|
1474
|
+
@if (usesDate()) {
|
|
1475
|
+
<div class="date-time-picker-header">
|
|
1476
|
+
<button
|
|
1477
|
+
type="button"
|
|
1478
|
+
class="date-time-picker-previous-year"
|
|
1479
|
+
[attr.aria-label]="labels().previousYear"
|
|
1480
|
+
(click)="shiftYear(-1)"
|
|
1481
|
+
>
|
|
1482
|
+
<span aria-hidden="true">«</span>
|
|
1483
|
+
</button>
|
|
1484
|
+
<button
|
|
1485
|
+
type="button"
|
|
1486
|
+
class="date-time-picker-previous-month"
|
|
1487
|
+
[attr.aria-label]="labels().previousMonth"
|
|
1488
|
+
(click)="shiftMonth(-1)"
|
|
1489
|
+
>
|
|
1490
|
+
<span aria-hidden="true">‹</span>
|
|
1491
|
+
</button>
|
|
1492
|
+
<button
|
|
1493
|
+
type="button"
|
|
1494
|
+
class="date-time-picker-previous-week"
|
|
1495
|
+
[attr.aria-label]="labels().previousWeek"
|
|
1496
|
+
(click)="shiftDays(-7)"
|
|
1497
|
+
>
|
|
1498
|
+
<span aria-hidden="true">‹‹</span>
|
|
1499
|
+
</button>
|
|
1500
|
+
<button
|
|
1501
|
+
type="button"
|
|
1502
|
+
class="date-time-picker-previous-day"
|
|
1503
|
+
[attr.aria-label]="labels().previousDay"
|
|
1504
|
+
(click)="shiftDays(-1)"
|
|
1505
|
+
>
|
|
1506
|
+
<span aria-hidden="true">‹</span>
|
|
1507
|
+
</button>
|
|
1508
|
+
|
|
1509
|
+
<!-- Polite, not assertive: paging months is the visible result
|
|
1510
|
+
of the user's own keypress, so it should reach a screen
|
|
1511
|
+
reader without interrupting anything. -->
|
|
1512
|
+
<span
|
|
1513
|
+
class="date-time-picker-period"
|
|
1514
|
+
[id]="periodId"
|
|
1515
|
+
aria-live="polite"
|
|
1516
|
+
>{{ periodText() }}</span
|
|
1517
|
+
>
|
|
1518
|
+
|
|
1519
|
+
<button
|
|
1520
|
+
type="button"
|
|
1521
|
+
class="date-time-picker-next-day"
|
|
1522
|
+
[attr.aria-label]="labels().nextDay"
|
|
1523
|
+
(click)="shiftDays(1)"
|
|
1524
|
+
>
|
|
1525
|
+
<span aria-hidden="true">›</span>
|
|
1526
|
+
</button>
|
|
1527
|
+
<button
|
|
1528
|
+
type="button"
|
|
1529
|
+
class="date-time-picker-next-week"
|
|
1530
|
+
[attr.aria-label]="labels().nextWeek"
|
|
1531
|
+
(click)="shiftDays(7)"
|
|
1532
|
+
>
|
|
1533
|
+
<span aria-hidden="true">››</span>
|
|
1534
|
+
</button>
|
|
1535
|
+
<button
|
|
1536
|
+
type="button"
|
|
1537
|
+
class="date-time-picker-next-month"
|
|
1538
|
+
[attr.aria-label]="labels().nextMonth"
|
|
1539
|
+
(click)="shiftMonth(1)"
|
|
1540
|
+
>
|
|
1541
|
+
<span aria-hidden="true">›</span>
|
|
1542
|
+
</button>
|
|
1543
|
+
<button
|
|
1544
|
+
type="button"
|
|
1545
|
+
class="date-time-picker-next-year"
|
|
1546
|
+
[attr.aria-label]="labels().nextYear"
|
|
1547
|
+
(click)="shiftYear(1)"
|
|
1548
|
+
>
|
|
1549
|
+
<span aria-hidden="true">»</span>
|
|
1550
|
+
</button>
|
|
1551
|
+
</div>
|
|
1552
|
+
|
|
1553
|
+
@if (labels().timeZone) {
|
|
1554
|
+
<!-- Before the grid, so the zone is chosen before the instant.
|
|
1555
|
+
The empty first option is the "no zone" state: the picker
|
|
1556
|
+
never guesses one from the runtime. -->
|
|
1557
|
+
<div class="date-time-picker-time-zone">
|
|
1558
|
+
<label
|
|
1559
|
+
class="date-time-picker-time-zone-label"
|
|
1560
|
+
[for]="timeZoneId"
|
|
1561
|
+
>{{ labels().timeZone }}</label
|
|
1562
|
+
>
|
|
1563
|
+
<!-- A parent [value] binding on <select> races the @for
|
|
1564
|
+
block's own per-option bindings: Angular's update pass
|
|
1565
|
+
can apply the select's value before the dynamically
|
|
1566
|
+
created <option> elements have their own [value]
|
|
1567
|
+
applied, so the browser matches nothing. Binding
|
|
1568
|
+
[selected] on each <option> instead selects correctly
|
|
1569
|
+
regardless of that ordering, because it is evaluated as
|
|
1570
|
+
part of the same per-item update as the option's own
|
|
1571
|
+
value/text. -->
|
|
1572
|
+
<select
|
|
1573
|
+
class="date-time-picker-time-zone-select"
|
|
1574
|
+
[id]="timeZoneId"
|
|
1575
|
+
(change)="onTimeZoneSelect($event)"
|
|
1576
|
+
>
|
|
1577
|
+
<option value="" [selected]="timeZone() === ''"></option>
|
|
1578
|
+
@for (zone of zoneOptions(); track zone) {
|
|
1579
|
+
<option [value]="zone" [selected]="zone === timeZone()">
|
|
1580
|
+
{{ zoneLabel(zone) }}
|
|
1581
|
+
</option>
|
|
1582
|
+
}
|
|
1583
|
+
</select>
|
|
1584
|
+
</div>
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
<!-- The grid owns its own keyboard contract, which is why the
|
|
1588
|
+
handler sits on the table rather than on each of 42 cells. -->
|
|
1589
|
+
<table
|
|
1590
|
+
#gridEl
|
|
1591
|
+
class="date-time-picker-calendar"
|
|
1592
|
+
role="grid"
|
|
1593
|
+
[attr.aria-labelledby]="periodId"
|
|
1594
|
+
(keydown)="onGridKeydown($event)"
|
|
1595
|
+
>
|
|
1596
|
+
<thead>
|
|
1597
|
+
<tr>
|
|
1598
|
+
@if (showWeekNumbers()) {
|
|
1599
|
+
<th
|
|
1600
|
+
class="date-time-picker-week-heading"
|
|
1601
|
+
scope="col"
|
|
1602
|
+
[attr.abbr]="labels().week"
|
|
1603
|
+
>
|
|
1604
|
+
{{ labels().week }}
|
|
1605
|
+
</th>
|
|
1606
|
+
}
|
|
1607
|
+
@for (weekday of weekdays(); track $index) {
|
|
1608
|
+
<!-- abbr carries the full weekday name, so a screen
|
|
1609
|
+
reader announcing the column says "Monday" where the
|
|
1610
|
+
eye reads "Mo". -->
|
|
1611
|
+
<th
|
|
1612
|
+
class="date-time-picker-weekday"
|
|
1613
|
+
scope="col"
|
|
1614
|
+
[attr.abbr]="weekday.long"
|
|
1615
|
+
>
|
|
1616
|
+
{{ weekday.short }}
|
|
1617
|
+
</th>
|
|
1618
|
+
}
|
|
1619
|
+
</tr>
|
|
1620
|
+
</thead>
|
|
1621
|
+
<tbody>
|
|
1622
|
+
@for (week of weeks(); track week[0]) {
|
|
1623
|
+
<tr>
|
|
1624
|
+
@if (showWeekNumbers()) {
|
|
1625
|
+
<th class="date-time-picker-week" scope="row">
|
|
1626
|
+
{{ isoWeekOf(week[0]) }}
|
|
1627
|
+
</th>
|
|
1628
|
+
}
|
|
1629
|
+
@for (isoDate of week; track isoDate) {
|
|
1630
|
+
@let cell = parseCell(isoDate);
|
|
1631
|
+
@let isToday = isoDate === today();
|
|
1632
|
+
@let isSelected = isoDate === pendingDate();
|
|
1633
|
+
@let isDisabled = dayDisabled(isoDate);
|
|
1634
|
+
<td role="gridcell" [attr.aria-selected]="isSelected">
|
|
1635
|
+
<!-- aria-disabled, not the disabled attribute: a
|
|
1636
|
+
vetoed day must stay focusable so the roving
|
|
1637
|
+
cursor can land on it and a screen reader can
|
|
1638
|
+
announce it as unavailable. A disabled button
|
|
1639
|
+
refuses focus, and arrowing across a blocked
|
|
1640
|
+
week goes silent while the visible focus stays
|
|
1641
|
+
behind. Activation is refused in selectDay
|
|
1642
|
+
instead. -->
|
|
1643
|
+
<button
|
|
1644
|
+
type="button"
|
|
1645
|
+
class="date-time-picker-day"
|
|
1646
|
+
[attr.data-date]="isoDate"
|
|
1647
|
+
[attr.data-outside]="cell.outside ? '' : null"
|
|
1648
|
+
[attr.data-today]="isToday ? '' : null"
|
|
1649
|
+
[attr.data-selected]="isSelected ? '' : null"
|
|
1650
|
+
[attr.data-disabled]="isDisabled ? '' : null"
|
|
1651
|
+
[attr.tabindex]="isoDate === cursor() ? 0 : -1"
|
|
1652
|
+
[attr.aria-label]="dayLabel(isoDate)"
|
|
1653
|
+
[attr.aria-current]="isToday ? 'date' : null"
|
|
1654
|
+
[attr.aria-disabled]="isDisabled ? 'true' : null"
|
|
1655
|
+
(click)="selectDay(isoDate)"
|
|
1656
|
+
>
|
|
1657
|
+
{{ cell.day }}
|
|
1658
|
+
</button>
|
|
1659
|
+
</td>
|
|
1660
|
+
}
|
|
1661
|
+
</tr>
|
|
1662
|
+
}
|
|
1663
|
+
</tbody>
|
|
1664
|
+
</table>
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
@if (usesTime()) {
|
|
1668
|
+
<div class="date-time-picker-time">
|
|
1669
|
+
<label class="date-time-picker-time-label" [for]="hourId">
|
|
1670
|
+
{{ labels().hour }}
|
|
1671
|
+
</label>
|
|
1672
|
+
<select
|
|
1673
|
+
class="date-time-picker-hour"
|
|
1674
|
+
[id]="hourId"
|
|
1675
|
+
[value]="pendingHour()"
|
|
1676
|
+
(change)="setHour(+$any($event.target).value)"
|
|
1677
|
+
>
|
|
1678
|
+
@for (option of hourOptions(); track option.value) {
|
|
1679
|
+
<option [value]="option.value">{{ option.label }}</option>
|
|
1680
|
+
}
|
|
1681
|
+
</select>
|
|
1682
|
+
|
|
1683
|
+
<label class="date-time-picker-time-label" [for]="minuteId">
|
|
1684
|
+
{{ labels().minute }}
|
|
1685
|
+
</label>
|
|
1686
|
+
<select
|
|
1687
|
+
class="date-time-picker-minute"
|
|
1688
|
+
[id]="minuteId"
|
|
1689
|
+
[value]="pendingMinute()"
|
|
1690
|
+
(change)="setMinute(+$any($event.target).value)"
|
|
1691
|
+
>
|
|
1692
|
+
@for (minute of minuteOptions(); track minute) {
|
|
1693
|
+
<option [value]="minute">{{ pad2(minute) }}</option>
|
|
1694
|
+
}
|
|
1695
|
+
</select>
|
|
1696
|
+
|
|
1697
|
+
@if (clock12()) {
|
|
1698
|
+
<label class="date-time-picker-time-label" [for]="meridiemId">
|
|
1699
|
+
{{ labels().meridiem }}
|
|
1700
|
+
</label>
|
|
1701
|
+
<select
|
|
1702
|
+
class="date-time-picker-meridiem"
|
|
1703
|
+
[id]="meridiemId"
|
|
1704
|
+
[value]="pendingHour() >= 12 ? 'pm' : 'am'"
|
|
1705
|
+
(change)="setMeridiem($any($event.target).value === 'pm')"
|
|
1706
|
+
>
|
|
1707
|
+
<!-- Stable values, localised labels: the visible text is
|
|
1708
|
+
the locale's own day-period name. -->
|
|
1709
|
+
<option value="am">{{ dayPeriodName(false) }}</option>
|
|
1710
|
+
<option value="pm">{{ dayPeriodName(true) }}</option>
|
|
1711
|
+
</select>
|
|
1712
|
+
}
|
|
1713
|
+
</div>
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
@if (shortcuts().length > 0) {
|
|
1717
|
+
<div class="date-time-picker-shortcuts">
|
|
1718
|
+
@for (shortcut of shortcuts(); track shortcut.id) {
|
|
1719
|
+
<button
|
|
1720
|
+
type="button"
|
|
1721
|
+
class="date-time-picker-shortcut"
|
|
1722
|
+
[attr.data-shortcut-id]="shortcut.id"
|
|
1723
|
+
(click)="applyShortcut(shortcut)"
|
|
1724
|
+
>
|
|
1725
|
+
{{ shortcut.label }}
|
|
1726
|
+
</button>
|
|
1727
|
+
}
|
|
1728
|
+
</div>
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
<div class="date-time-picker-footer">
|
|
1732
|
+
@if (labels().clear) {
|
|
1733
|
+
<button
|
|
1734
|
+
type="button"
|
|
1735
|
+
class="date-time-picker-clear"
|
|
1736
|
+
(click)="clear()"
|
|
1737
|
+
>
|
|
1738
|
+
{{ labels().clear }}
|
|
1739
|
+
</button>
|
|
1740
|
+
}
|
|
1741
|
+
<button
|
|
1742
|
+
type="button"
|
|
1743
|
+
class="date-time-picker-cancel"
|
|
1744
|
+
(click)="closeDialog()"
|
|
1745
|
+
>
|
|
1746
|
+
{{ labels().cancel }}
|
|
1747
|
+
</button>
|
|
1748
|
+
<button
|
|
1749
|
+
type="button"
|
|
1750
|
+
class="date-time-picker-confirm"
|
|
1751
|
+
(click)="commit()"
|
|
1752
|
+
>
|
|
1753
|
+
{{ labels().confirm }}
|
|
1754
|
+
</button>
|
|
1755
|
+
</div>
|
|
1756
|
+
</div>
|
|
1757
|
+
</div>
|
|
1758
|
+
`, isInline: true, dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
1759
|
+
}
|
|
1760
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.4", ngImport: i0, type: DateTimePicker, decorators: [{
|
|
1761
|
+
type: Component,
|
|
1762
|
+
args: [{
|
|
1763
|
+
selector: "lily-date-time-picker",
|
|
1764
|
+
standalone: true,
|
|
1765
|
+
imports: [NgTemplateOutlet],
|
|
1766
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
1767
|
+
host: {
|
|
1768
|
+
"(document:click)": "onDocumentClick($event)",
|
|
1769
|
+
},
|
|
1770
|
+
template: `
|
|
1771
|
+
<div
|
|
1772
|
+
#rootEl
|
|
1773
|
+
class="date-time-picker {{ className() }}"
|
|
1774
|
+
[attr.data-mode]="mode()"
|
|
1775
|
+
[attr.data-time-zone]="timeZone() || null"
|
|
1776
|
+
>
|
|
1777
|
+
<input type="hidden" [name]="name()" [value]="value()" />
|
|
1778
|
+
@if (labels().timeZone) {
|
|
1779
|
+
<input
|
|
1780
|
+
type="hidden"
|
|
1781
|
+
[name]="name() + '-time-zone'"
|
|
1782
|
+
[value]="timeZone()"
|
|
1783
|
+
/>
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
<div class="date-time-picker-field">
|
|
1787
|
+
<input
|
|
1788
|
+
class="date-time-picker-input"
|
|
1789
|
+
[id]="fieldId()"
|
|
1790
|
+
type="text"
|
|
1791
|
+
autocomplete="off"
|
|
1792
|
+
[value]="display()"
|
|
1793
|
+
[attr.placeholder]="placeholder() || null"
|
|
1794
|
+
[disabled]="disabled()"
|
|
1795
|
+
[readOnly]="readOnly()"
|
|
1796
|
+
[required]="required()"
|
|
1797
|
+
[attr.aria-describedby]="fieldDescribedBy()"
|
|
1798
|
+
[attr.aria-invalid]="invalid() ? 'true' : null"
|
|
1799
|
+
[attr.aria-errormessage]="
|
|
1800
|
+
invalid() && labels().invalid ? statusId : null
|
|
1801
|
+
"
|
|
1802
|
+
(input)="typed.set($any($event.target).value)"
|
|
1803
|
+
(blur)="resolveTyped()"
|
|
1804
|
+
(keydown)="onFieldKeydown($event)"
|
|
1805
|
+
/>
|
|
1806
|
+
|
|
1807
|
+
<button
|
|
1808
|
+
#buttonEl
|
|
1809
|
+
type="button"
|
|
1810
|
+
class="date-time-picker-button"
|
|
1811
|
+
[attr.aria-label]="label()"
|
|
1812
|
+
aria-haspopup="dialog"
|
|
1813
|
+
[attr.aria-expanded]="open()"
|
|
1814
|
+
[attr.aria-controls]="dialogId"
|
|
1815
|
+
[disabled]="disabled() || readOnly()"
|
|
1816
|
+
(click)="open() ? closeDialog() : openDialog()"
|
|
1817
|
+
>
|
|
1818
|
+
@if (iconTemplate(); as tpl) {
|
|
1819
|
+
<ng-container
|
|
1820
|
+
[ngTemplateOutlet]="tpl"
|
|
1821
|
+
[ngTemplateOutletContext]="childContext()"
|
|
1822
|
+
/>
|
|
1823
|
+
} @else {
|
|
1824
|
+
<span class="date-time-picker-icon" aria-hidden="true">{{
|
|
1825
|
+
glyph
|
|
1826
|
+
}}</span>
|
|
1827
|
+
}
|
|
1828
|
+
</button>
|
|
1829
|
+
</div>
|
|
1830
|
+
|
|
1831
|
+
@if (labels().invalid) {
|
|
1832
|
+
<!-- Present in the DOM before it has content: a live region that
|
|
1833
|
+
appears at the same moment as its message is routinely not
|
|
1834
|
+
announced at all. Empty while the field is valid. -->
|
|
1835
|
+
<span class="date-time-picker-status" [id]="statusId" role="status">{{
|
|
1836
|
+
invalid() ? labels().invalid : ""
|
|
1837
|
+
}}</span>
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
<div
|
|
1841
|
+
#dialogEl
|
|
1842
|
+
class="date-time-picker-dialog"
|
|
1843
|
+
[id]="dialogId"
|
|
1844
|
+
role="dialog"
|
|
1845
|
+
aria-modal="true"
|
|
1846
|
+
[attr.aria-label]="label()"
|
|
1847
|
+
[attr.aria-describedby]="labels().instructions ? instructionsId : null"
|
|
1848
|
+
tabindex="-1"
|
|
1849
|
+
[attr.hidden]="open() ? null : ''"
|
|
1850
|
+
(keydown)="onDialogKeydown($event)"
|
|
1851
|
+
>
|
|
1852
|
+
@if (labels().instructions) {
|
|
1853
|
+
<!-- Spoken once when the dialog takes focus, via the dialog's
|
|
1854
|
+
aria-describedby. Visible by default; a consumer who wants
|
|
1855
|
+
it screen-reader-only hides it with their own CSS. -->
|
|
1856
|
+
<p class="date-time-picker-instructions" [id]="instructionsId">
|
|
1857
|
+
{{ labels().instructions }}
|
|
1858
|
+
</p>
|
|
1859
|
+
}
|
|
1860
|
+
@if (usesDate()) {
|
|
1861
|
+
<div class="date-time-picker-header">
|
|
1862
|
+
<button
|
|
1863
|
+
type="button"
|
|
1864
|
+
class="date-time-picker-previous-year"
|
|
1865
|
+
[attr.aria-label]="labels().previousYear"
|
|
1866
|
+
(click)="shiftYear(-1)"
|
|
1867
|
+
>
|
|
1868
|
+
<span aria-hidden="true">«</span>
|
|
1869
|
+
</button>
|
|
1870
|
+
<button
|
|
1871
|
+
type="button"
|
|
1872
|
+
class="date-time-picker-previous-month"
|
|
1873
|
+
[attr.aria-label]="labels().previousMonth"
|
|
1874
|
+
(click)="shiftMonth(-1)"
|
|
1875
|
+
>
|
|
1876
|
+
<span aria-hidden="true">‹</span>
|
|
1877
|
+
</button>
|
|
1878
|
+
<button
|
|
1879
|
+
type="button"
|
|
1880
|
+
class="date-time-picker-previous-week"
|
|
1881
|
+
[attr.aria-label]="labels().previousWeek"
|
|
1882
|
+
(click)="shiftDays(-7)"
|
|
1883
|
+
>
|
|
1884
|
+
<span aria-hidden="true">‹‹</span>
|
|
1885
|
+
</button>
|
|
1886
|
+
<button
|
|
1887
|
+
type="button"
|
|
1888
|
+
class="date-time-picker-previous-day"
|
|
1889
|
+
[attr.aria-label]="labels().previousDay"
|
|
1890
|
+
(click)="shiftDays(-1)"
|
|
1891
|
+
>
|
|
1892
|
+
<span aria-hidden="true">‹</span>
|
|
1893
|
+
</button>
|
|
1894
|
+
|
|
1895
|
+
<!-- Polite, not assertive: paging months is the visible result
|
|
1896
|
+
of the user's own keypress, so it should reach a screen
|
|
1897
|
+
reader without interrupting anything. -->
|
|
1898
|
+
<span
|
|
1899
|
+
class="date-time-picker-period"
|
|
1900
|
+
[id]="periodId"
|
|
1901
|
+
aria-live="polite"
|
|
1902
|
+
>{{ periodText() }}</span
|
|
1903
|
+
>
|
|
1904
|
+
|
|
1905
|
+
<button
|
|
1906
|
+
type="button"
|
|
1907
|
+
class="date-time-picker-next-day"
|
|
1908
|
+
[attr.aria-label]="labels().nextDay"
|
|
1909
|
+
(click)="shiftDays(1)"
|
|
1910
|
+
>
|
|
1911
|
+
<span aria-hidden="true">›</span>
|
|
1912
|
+
</button>
|
|
1913
|
+
<button
|
|
1914
|
+
type="button"
|
|
1915
|
+
class="date-time-picker-next-week"
|
|
1916
|
+
[attr.aria-label]="labels().nextWeek"
|
|
1917
|
+
(click)="shiftDays(7)"
|
|
1918
|
+
>
|
|
1919
|
+
<span aria-hidden="true">››</span>
|
|
1920
|
+
</button>
|
|
1921
|
+
<button
|
|
1922
|
+
type="button"
|
|
1923
|
+
class="date-time-picker-next-month"
|
|
1924
|
+
[attr.aria-label]="labels().nextMonth"
|
|
1925
|
+
(click)="shiftMonth(1)"
|
|
1926
|
+
>
|
|
1927
|
+
<span aria-hidden="true">›</span>
|
|
1928
|
+
</button>
|
|
1929
|
+
<button
|
|
1930
|
+
type="button"
|
|
1931
|
+
class="date-time-picker-next-year"
|
|
1932
|
+
[attr.aria-label]="labels().nextYear"
|
|
1933
|
+
(click)="shiftYear(1)"
|
|
1934
|
+
>
|
|
1935
|
+
<span aria-hidden="true">»</span>
|
|
1936
|
+
</button>
|
|
1937
|
+
</div>
|
|
1938
|
+
|
|
1939
|
+
@if (labels().timeZone) {
|
|
1940
|
+
<!-- Before the grid, so the zone is chosen before the instant.
|
|
1941
|
+
The empty first option is the "no zone" state: the picker
|
|
1942
|
+
never guesses one from the runtime. -->
|
|
1943
|
+
<div class="date-time-picker-time-zone">
|
|
1944
|
+
<label
|
|
1945
|
+
class="date-time-picker-time-zone-label"
|
|
1946
|
+
[for]="timeZoneId"
|
|
1947
|
+
>{{ labels().timeZone }}</label
|
|
1948
|
+
>
|
|
1949
|
+
<!-- A parent [value] binding on <select> races the @for
|
|
1950
|
+
block's own per-option bindings: Angular's update pass
|
|
1951
|
+
can apply the select's value before the dynamically
|
|
1952
|
+
created <option> elements have their own [value]
|
|
1953
|
+
applied, so the browser matches nothing. Binding
|
|
1954
|
+
[selected] on each <option> instead selects correctly
|
|
1955
|
+
regardless of that ordering, because it is evaluated as
|
|
1956
|
+
part of the same per-item update as the option's own
|
|
1957
|
+
value/text. -->
|
|
1958
|
+
<select
|
|
1959
|
+
class="date-time-picker-time-zone-select"
|
|
1960
|
+
[id]="timeZoneId"
|
|
1961
|
+
(change)="onTimeZoneSelect($event)"
|
|
1962
|
+
>
|
|
1963
|
+
<option value="" [selected]="timeZone() === ''"></option>
|
|
1964
|
+
@for (zone of zoneOptions(); track zone) {
|
|
1965
|
+
<option [value]="zone" [selected]="zone === timeZone()">
|
|
1966
|
+
{{ zoneLabel(zone) }}
|
|
1967
|
+
</option>
|
|
1968
|
+
}
|
|
1969
|
+
</select>
|
|
1970
|
+
</div>
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
<!-- The grid owns its own keyboard contract, which is why the
|
|
1974
|
+
handler sits on the table rather than on each of 42 cells. -->
|
|
1975
|
+
<table
|
|
1976
|
+
#gridEl
|
|
1977
|
+
class="date-time-picker-calendar"
|
|
1978
|
+
role="grid"
|
|
1979
|
+
[attr.aria-labelledby]="periodId"
|
|
1980
|
+
(keydown)="onGridKeydown($event)"
|
|
1981
|
+
>
|
|
1982
|
+
<thead>
|
|
1983
|
+
<tr>
|
|
1984
|
+
@if (showWeekNumbers()) {
|
|
1985
|
+
<th
|
|
1986
|
+
class="date-time-picker-week-heading"
|
|
1987
|
+
scope="col"
|
|
1988
|
+
[attr.abbr]="labels().week"
|
|
1989
|
+
>
|
|
1990
|
+
{{ labels().week }}
|
|
1991
|
+
</th>
|
|
1992
|
+
}
|
|
1993
|
+
@for (weekday of weekdays(); track $index) {
|
|
1994
|
+
<!-- abbr carries the full weekday name, so a screen
|
|
1995
|
+
reader announcing the column says "Monday" where the
|
|
1996
|
+
eye reads "Mo". -->
|
|
1997
|
+
<th
|
|
1998
|
+
class="date-time-picker-weekday"
|
|
1999
|
+
scope="col"
|
|
2000
|
+
[attr.abbr]="weekday.long"
|
|
2001
|
+
>
|
|
2002
|
+
{{ weekday.short }}
|
|
2003
|
+
</th>
|
|
2004
|
+
}
|
|
2005
|
+
</tr>
|
|
2006
|
+
</thead>
|
|
2007
|
+
<tbody>
|
|
2008
|
+
@for (week of weeks(); track week[0]) {
|
|
2009
|
+
<tr>
|
|
2010
|
+
@if (showWeekNumbers()) {
|
|
2011
|
+
<th class="date-time-picker-week" scope="row">
|
|
2012
|
+
{{ isoWeekOf(week[0]) }}
|
|
2013
|
+
</th>
|
|
2014
|
+
}
|
|
2015
|
+
@for (isoDate of week; track isoDate) {
|
|
2016
|
+
@let cell = parseCell(isoDate);
|
|
2017
|
+
@let isToday = isoDate === today();
|
|
2018
|
+
@let isSelected = isoDate === pendingDate();
|
|
2019
|
+
@let isDisabled = dayDisabled(isoDate);
|
|
2020
|
+
<td role="gridcell" [attr.aria-selected]="isSelected">
|
|
2021
|
+
<!-- aria-disabled, not the disabled attribute: a
|
|
2022
|
+
vetoed day must stay focusable so the roving
|
|
2023
|
+
cursor can land on it and a screen reader can
|
|
2024
|
+
announce it as unavailable. A disabled button
|
|
2025
|
+
refuses focus, and arrowing across a blocked
|
|
2026
|
+
week goes silent while the visible focus stays
|
|
2027
|
+
behind. Activation is refused in selectDay
|
|
2028
|
+
instead. -->
|
|
2029
|
+
<button
|
|
2030
|
+
type="button"
|
|
2031
|
+
class="date-time-picker-day"
|
|
2032
|
+
[attr.data-date]="isoDate"
|
|
2033
|
+
[attr.data-outside]="cell.outside ? '' : null"
|
|
2034
|
+
[attr.data-today]="isToday ? '' : null"
|
|
2035
|
+
[attr.data-selected]="isSelected ? '' : null"
|
|
2036
|
+
[attr.data-disabled]="isDisabled ? '' : null"
|
|
2037
|
+
[attr.tabindex]="isoDate === cursor() ? 0 : -1"
|
|
2038
|
+
[attr.aria-label]="dayLabel(isoDate)"
|
|
2039
|
+
[attr.aria-current]="isToday ? 'date' : null"
|
|
2040
|
+
[attr.aria-disabled]="isDisabled ? 'true' : null"
|
|
2041
|
+
(click)="selectDay(isoDate)"
|
|
2042
|
+
>
|
|
2043
|
+
{{ cell.day }}
|
|
2044
|
+
</button>
|
|
2045
|
+
</td>
|
|
2046
|
+
}
|
|
2047
|
+
</tr>
|
|
2048
|
+
}
|
|
2049
|
+
</tbody>
|
|
2050
|
+
</table>
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
@if (usesTime()) {
|
|
2054
|
+
<div class="date-time-picker-time">
|
|
2055
|
+
<label class="date-time-picker-time-label" [for]="hourId">
|
|
2056
|
+
{{ labels().hour }}
|
|
2057
|
+
</label>
|
|
2058
|
+
<select
|
|
2059
|
+
class="date-time-picker-hour"
|
|
2060
|
+
[id]="hourId"
|
|
2061
|
+
[value]="pendingHour()"
|
|
2062
|
+
(change)="setHour(+$any($event.target).value)"
|
|
2063
|
+
>
|
|
2064
|
+
@for (option of hourOptions(); track option.value) {
|
|
2065
|
+
<option [value]="option.value">{{ option.label }}</option>
|
|
2066
|
+
}
|
|
2067
|
+
</select>
|
|
2068
|
+
|
|
2069
|
+
<label class="date-time-picker-time-label" [for]="minuteId">
|
|
2070
|
+
{{ labels().minute }}
|
|
2071
|
+
</label>
|
|
2072
|
+
<select
|
|
2073
|
+
class="date-time-picker-minute"
|
|
2074
|
+
[id]="minuteId"
|
|
2075
|
+
[value]="pendingMinute()"
|
|
2076
|
+
(change)="setMinute(+$any($event.target).value)"
|
|
2077
|
+
>
|
|
2078
|
+
@for (minute of minuteOptions(); track minute) {
|
|
2079
|
+
<option [value]="minute">{{ pad2(minute) }}</option>
|
|
2080
|
+
}
|
|
2081
|
+
</select>
|
|
2082
|
+
|
|
2083
|
+
@if (clock12()) {
|
|
2084
|
+
<label class="date-time-picker-time-label" [for]="meridiemId">
|
|
2085
|
+
{{ labels().meridiem }}
|
|
2086
|
+
</label>
|
|
2087
|
+
<select
|
|
2088
|
+
class="date-time-picker-meridiem"
|
|
2089
|
+
[id]="meridiemId"
|
|
2090
|
+
[value]="pendingHour() >= 12 ? 'pm' : 'am'"
|
|
2091
|
+
(change)="setMeridiem($any($event.target).value === 'pm')"
|
|
2092
|
+
>
|
|
2093
|
+
<!-- Stable values, localised labels: the visible text is
|
|
2094
|
+
the locale's own day-period name. -->
|
|
2095
|
+
<option value="am">{{ dayPeriodName(false) }}</option>
|
|
2096
|
+
<option value="pm">{{ dayPeriodName(true) }}</option>
|
|
2097
|
+
</select>
|
|
2098
|
+
}
|
|
2099
|
+
</div>
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
@if (shortcuts().length > 0) {
|
|
2103
|
+
<div class="date-time-picker-shortcuts">
|
|
2104
|
+
@for (shortcut of shortcuts(); track shortcut.id) {
|
|
2105
|
+
<button
|
|
2106
|
+
type="button"
|
|
2107
|
+
class="date-time-picker-shortcut"
|
|
2108
|
+
[attr.data-shortcut-id]="shortcut.id"
|
|
2109
|
+
(click)="applyShortcut(shortcut)"
|
|
2110
|
+
>
|
|
2111
|
+
{{ shortcut.label }}
|
|
2112
|
+
</button>
|
|
2113
|
+
}
|
|
2114
|
+
</div>
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
<div class="date-time-picker-footer">
|
|
2118
|
+
@if (labels().clear) {
|
|
2119
|
+
<button
|
|
2120
|
+
type="button"
|
|
2121
|
+
class="date-time-picker-clear"
|
|
2122
|
+
(click)="clear()"
|
|
2123
|
+
>
|
|
2124
|
+
{{ labels().clear }}
|
|
2125
|
+
</button>
|
|
2126
|
+
}
|
|
2127
|
+
<button
|
|
2128
|
+
type="button"
|
|
2129
|
+
class="date-time-picker-cancel"
|
|
2130
|
+
(click)="closeDialog()"
|
|
2131
|
+
>
|
|
2132
|
+
{{ labels().cancel }}
|
|
2133
|
+
</button>
|
|
2134
|
+
<button
|
|
2135
|
+
type="button"
|
|
2136
|
+
class="date-time-picker-confirm"
|
|
2137
|
+
(click)="commit()"
|
|
2138
|
+
>
|
|
2139
|
+
{{ labels().confirm }}
|
|
2140
|
+
</button>
|
|
2141
|
+
</div>
|
|
2142
|
+
</div>
|
|
2143
|
+
</div>
|
|
2144
|
+
`,
|
|
2145
|
+
}]
|
|
2146
|
+
}], ctorParameters: () => [], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: true }] }], labels: [{ type: i0.Input, args: [{ isSignal: true, alias: "labels", required: true }] }], mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], locale: [{ type: i0.Input, args: [{ isSignal: true, alias: "locale", required: false }] }], min: [{ type: i0.Input, args: [{ isSignal: true, alias: "min", required: false }] }], max: [{ type: i0.Input, args: [{ isSignal: true, alias: "max", required: false }] }], isDateDisabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "isDateDisabled", required: false }] }], firstDayOfWeek: [{ type: i0.Input, args: [{ isSignal: true, alias: "firstDayOfWeek", required: false }] }], minuteStep: [{ type: i0.Input, args: [{ isSignal: true, alias: "minuteStep", required: false }] }], hour12: [{ type: i0.Input, args: [{ isSignal: true, alias: "hour12", required: false }] }], showWeekNumbers: [{ type: i0.Input, args: [{ isSignal: true, alias: "showWeekNumbers", required: false }] }], shortcuts: [{ type: i0.Input, args: [{ isSignal: true, alias: "shortcuts", required: false }] }], confirmOnSelect: [{ type: i0.Input, args: [{ isSignal: true, alias: "confirmOnSelect", required: false }] }], name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: false }] }], timeZone: [{ type: i0.Input, args: [{ isSignal: true, alias: "timeZone", required: false }] }, { type: i0.Output, args: ["timeZoneChange"] }], timeZones: [{ type: i0.Input, args: [{ isSignal: true, alias: "timeZones", required: false }] }], timeZoneLabels: [{ type: i0.Input, args: [{ isSignal: true, alias: "timeZoneLabels", required: false }] }], inputId: [{ type: i0.Input, args: [{ isSignal: true, alias: "inputId", required: false }] }], describedBy: [{ type: i0.Input, args: [{ isSignal: true, alias: "describedBy", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], readOnly: [{ type: i0.Input, args: [{ isSignal: true, alias: "readOnly", required: false }] }], required: [{ type: i0.Input, args: [{ isSignal: true, alias: "required", required: false }] }], formatValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "formatValue", required: false }] }], parseInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "parseInput", required: false }] }], className: [{ type: i0.Input, args: [{ isSignal: true, alias: "className", required: false }] }], change: [{ type: i0.Output, args: ["change"] }], shortcut: [{ type: i0.Output, args: ["shortcut"] }], invalidInput: [{ type: i0.Output, args: ["invalidInput"] }], iconTemplate: [{ type: i0.ContentChild, args: [i0.forwardRef(() => TemplateRef), { isSignal: true }] }], rootRef: [{ type: i0.ViewChild, args: ["rootEl", { isSignal: true }] }], buttonRef: [{ type: i0.ViewChild, args: ["buttonEl", { isSignal: true }] }], dialogRef: [{ type: i0.ViewChild, args: ["dialogEl", { isSignal: true }] }], gridRef: [{ type: i0.ViewChild, args: ["gridEl", { isSignal: true }] }] } });
|
|
2147
|
+
|
|
2148
|
+
/**
|
|
2149
|
+
* Generated bundle index. Do not edit.
|
|
2150
|
+
*/
|
|
2151
|
+
|
|
2152
|
+
export { CALENDAR, DateTimePicker, DateTimePickerIcon, addDays, addMonths, daysInMonth, firstDayOfWeekFor, formatIsoDate, formatIsoTime, fromEpochDay, isoWeek, joinValue, monthMatrix, monthNames, nextDateTimePickerId, numericFieldOrder, pad, parseDateInput, parseIsoDate, parseIsoTime, parseTimeInput, splitValue, toEpochDay, weekdayOf, withinRange };
|
|
2153
|
+
//# sourceMappingURL=lilydesignsystem-angular-date-time-picker.mjs.map
|