@emuanalytics/flow-rdf-ui 4.1.27 → 4.2.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.
@@ -1,10 +1,13 @@
1
1
  import { html, nothing } from 'lit';
2
2
  import { query, state, property } from 'lit/decorators.js';
3
3
  import { classMap } from 'lit/directives/class-map.js';
4
- import { DateTime, Info } from 'luxon';
4
+ import { live } from 'lit/directives/live.js';
5
+ import { Interval, DateTime, Info } from 'luxon';
6
+ import { parseDateLimits, endTimeOfRange, atTimeOfDay, parseDateRange, lastDayOfRange } from '../../internal/date-range.mjs';
5
7
  import FlowRdfElement from '../../internal/flow-ui-rdf-element.mjs';
6
8
  import { isStringFull, isDefined } from '../../internal/type-checks.mjs';
7
9
  import { watch } from '../../internal/watch.mjs';
10
+ import { LocalizeController } from '../../utilities/localize.mjs';
8
11
  import styles from './calendar.styles.mjs';
9
12
 
10
13
  var __defProp = Object.defineProperty;
@@ -21,8 +24,13 @@ var __decorateClass = (decorators, target, key, kind) => {
21
24
  class FlowUiCalendar extends FlowRdfElement {
22
25
  constructor() {
23
26
  super(...arguments);
27
+ this._startTime = { hour: 0, minute: 0, second: 0 };
28
+ this._endTime = { hour: 24, minute: 0, second: 0 };
29
+ this.localize = new LocalizeController(this);
24
30
  this.displayMode = "date";
31
+ this.range = false;
25
32
  this.size = "medium";
33
+ this.months = 1;
26
34
  }
27
35
  connectedCallback() {
28
36
  super.connectedCallback();
@@ -30,23 +38,132 @@ class FlowUiCalendar extends FlowRdfElement {
30
38
  }
31
39
  disconnectedCallback() {
32
40
  super.disconnectedCallback();
41
+ this.clearPendingRange();
42
+ }
43
+ /** The number of months shown. Anything other than `2` shows one month. */
44
+ get monthCount() {
45
+ return +this.months === 2 ? 2 : 1;
46
+ }
47
+ /** Whether the calendar selects a range: a date range, a datetime range or a time range. */
48
+ get isRangeMode() {
49
+ return this.range;
50
+ }
51
+ /** Whether the calendar selects a datetime range, with start and end times. */
52
+ get isDateTimeRangeMode() {
53
+ return this.isRangeMode && this.displayMode === "datetime";
54
+ }
55
+ /** Whether the calendar selects a time range: start and end times, with no days. */
56
+ get isTimeRangeMode() {
57
+ return this.isRangeMode && this.displayMode === "time";
58
+ }
59
+ /** The parsed `min`, `max` and `max-range`, in the calendar's timezone. There are no limits in time display mode. */
60
+ get limits() {
61
+ if (this.displayMode === "time") {
62
+ return {};
63
+ }
64
+ return parseDateLimits(this.min, this.max, this.maxRange, { zone: this.timezone, locale: this.locale });
65
+ }
66
+ /**
67
+ * Whether the user can't select `day` (the start of a day): it is outside `min` or `max`, or, while a range is
68
+ * pending, it would make the range longer than `max-range`.
69
+ */
70
+ isDayDisabled(day, limits = this.limits) {
71
+ const { min, max, maxRange } = limits;
72
+ if (min && day < min.startOf("day") || max && day > max.startOf("day")) {
73
+ return true;
74
+ }
75
+ if (this.isRangeMode && this._pendingDay && maxRange) {
76
+ const [first, last] = day < this._pendingDay ? [day, this._pendingDay] : [this._pendingDay, day];
77
+ return first.plus(maxRange) < last.plus({ days: 1 }).startOf("day");
78
+ }
79
+ return false;
80
+ }
81
+ /** Clamp a date and time to `min` and `max`, so that the time inputs can't select a time outside the limits. */
82
+ clampToLimits(date) {
83
+ const { min, max } = this.limits;
84
+ if (min && date < min) {
85
+ return min;
86
+ }
87
+ return max && date > max ? max : date;
88
+ }
89
+ /** The first and last months the user can navigate to, as the start of each month, if `min` or `max` is set. */
90
+ get monthLimits() {
91
+ const { min, max } = this.limits;
92
+ const first = min?.startOf("month");
93
+ let last = max?.startOf("month").minus({ months: this.monthCount - 1 });
94
+ if (first && last && last < first) {
95
+ last = first;
96
+ }
97
+ return { first, last };
98
+ }
99
+ /** Clamp a month to the months the user can navigate to. */
100
+ clampMonth(month) {
101
+ const { first, last } = this.monthLimits;
102
+ if (first && month < first) {
103
+ return first;
104
+ }
105
+ return last && month > last ? last : month;
33
106
  }
34
107
  handleValueChange() {
108
+ if (this.isRangeMode) {
109
+ this.handleRangeValueChange();
110
+ return;
111
+ }
112
+ this._selectedRange = void 0;
113
+ this.clearPendingRange();
35
114
  this._selectedDate = this.makeDate(this.value)?.set({ millisecond: 0 });
36
115
  if (this._selectedDate) {
37
116
  if (this.displayMode === "date") {
38
117
  this._selectedDate = this._selectedDate.startOf("day");
39
118
  }
40
- this._displayDate = this._selectedDate.startOf("month");
119
+ this.showMonthOf(this._selectedDate);
41
120
  } else {
42
- this._displayDate = this.makeDate().startOf("month");
121
+ this.showMonthOf(this.makeDate());
43
122
  }
44
123
  }
124
+ handleRangeValueChange() {
125
+ const range = this.makeRange(this.value);
126
+ this._selectedDate = void 0;
127
+ if (range && this._selectedRange && +range.start === +this._selectedRange.start && +range.end === +this._selectedRange.end) {
128
+ return;
129
+ }
130
+ this._selectedRange = range;
131
+ this.clearPendingRange();
132
+ this.showMonthOf(range ? range.start : this.makeDate());
133
+ this._startTime = range ? { hour: range.start.hour, minute: range.start.minute, second: range.start.second } : { hour: 0, minute: 0, second: 0 };
134
+ this._endTime = range ? endTimeOfRange(range) : { hour: 24, minute: 0, second: 0 };
135
+ }
136
+ /**
137
+ * Show the month of `date` as the first month. In the two-month view, a date in the second month is already shown,
138
+ * so the display doesn't move.
139
+ */
140
+ showMonthOf(date) {
141
+ const month = date.startOf("month");
142
+ const isSecondMonth = this.monthCount === 2 && !!this._displayDate?.plus({ months: 1 }).hasSame(month, "month");
143
+ this._displayDate = isSecondMonth ? month.minus({ months: 1 }) : month;
144
+ }
45
145
  handleTimezoneChange() {
146
+ this._selectedRange = void 0;
46
147
  this.handleValueChange();
47
148
  this.updateValue();
48
149
  }
150
+ handleRangeChange() {
151
+ this._selectedRange = void 0;
152
+ this.handleValueChange();
153
+ }
154
+ handleDisplayModeChange() {
155
+ if (this.range) {
156
+ this.handleRangeChange();
157
+ }
158
+ }
49
159
  dateClicked(val) {
160
+ if (this.isDayDisabled(this.makeDate(val).startOf("day"))) {
161
+ return;
162
+ }
163
+ if (this.isRangeMode) {
164
+ this.rangeDateClicked(val);
165
+ return;
166
+ }
50
167
  const date = this.makeDate(val);
51
168
  if (this._selectedDate) {
52
169
  this._selectedDate = date.set({
@@ -57,17 +174,111 @@ class FlowUiCalendar extends FlowRdfElement {
57
174
  } else {
58
175
  this._selectedDate = date;
59
176
  }
177
+ if (this.displayMode === "datetime") {
178
+ this._selectedDate = this.clampToLimits(this._selectedDate);
179
+ }
60
180
  this.updateValue();
61
181
  }
182
+ /**
183
+ * Handle a day click in range mode. The first click sets the pending day. The second click completes the range,
184
+ * whichever order the two days were clicked in, so the user can click the last day first and then go back to the
185
+ * range start.
186
+ */
187
+ rangeDateClicked(val) {
188
+ const day = this.makeDate(val).startOf("day");
189
+ if (!this._pendingDay) {
190
+ if (this.isSingleDayOnly(day)) {
191
+ this.completeRange(day, day);
192
+ return;
193
+ }
194
+ this._pendingDay = day;
195
+ this._hoverDate = day;
196
+ return;
197
+ }
198
+ const [first, last] = day < this._pendingDay ? [day, this._pendingDay] : [this._pendingDay, day];
199
+ this.completeRange(first, last);
200
+ }
201
+ /** Whether `max-range` only allows ranges of one day starting on `day`, so that a range is a single day. */
202
+ isSingleDayOnly(day) {
203
+ const { maxRange } = this.limits;
204
+ return !!maxRange && day.plus(maxRange) < day.plus({ days: 2 }).startOf("day");
205
+ }
206
+ /** Complete a range from `first` to `last` (both the start of a day) and update the value. */
207
+ completeRange(first, last) {
208
+ const range = this.isDateTimeRangeMode ? this.makeDateTimeRange(first, last) : Interval.fromDateTimes(first, last.plus({ days: 1 }).startOf("day"));
209
+ this.clearPendingRange();
210
+ if (range.isValid) {
211
+ this._selectedRange = range;
212
+ if (this.isDateTimeRangeMode) {
213
+ this._startTime = { hour: range.start.hour, minute: range.start.minute, second: range.start.second };
214
+ this._endTime = endTimeOfRange(range);
215
+ }
216
+ this.updateValue();
217
+ }
218
+ }
219
+ /**
220
+ * The datetime range from the start time on `first` to the end time on `last` (both the start of a day), clamped to
221
+ * `min` and `max`. If the times would make the range empty, for example an end time before the start time on a
222
+ * single day, the end time is reset to 24:00:00. If the range would be longer than `max-range`, the end is moved
223
+ * back.
224
+ */
225
+ makeDateTimeRange(first, last) {
226
+ const start = this.clampToLimits(atTimeOfDay(first, this._startTime));
227
+ let end = this.clampToLimits(atTimeOfDay(last, this._endTime));
228
+ const { maxRange } = this.limits;
229
+ if (end <= start) {
230
+ end = this.clampToLimits(atTimeOfDay(last, { hour: 24, minute: 0, second: 0 }));
231
+ }
232
+ if (maxRange && start.plus(maxRange) < end) {
233
+ end = start.plus(maxRange);
234
+ }
235
+ return Interval.fromDateTimes(start, end);
236
+ }
237
+ dayHovered(val) {
238
+ const day = this.makeDate(val).startOf("day");
239
+ if (this._pendingDay && !this.isDayDisabled(day)) {
240
+ this._hoverDate = day;
241
+ }
242
+ }
243
+ /**
244
+ * Discard a pending range, so that the user can click a new range start. The value is unchanged. Returns `true` if a
245
+ * range was pending.
246
+ *
247
+ * @internal
248
+ */
249
+ cancelPendingRange() {
250
+ const wasPending = !!this._pendingDay;
251
+ this.clearPendingRange();
252
+ return wasPending;
253
+ }
254
+ handleKeyDown(event) {
255
+ if (event.key === "Escape" && this.cancelPendingRange()) {
256
+ event.preventDefault();
257
+ event.stopPropagation();
258
+ }
259
+ }
260
+ clearPendingRange() {
261
+ this._pendingDay = void 0;
262
+ this._hoverDate = void 0;
263
+ }
62
264
  stepDisplayDate(duration) {
63
- this._displayDate = this._displayDate.plus(duration);
265
+ this._displayDate = this.clampMonth(this._displayDate.plus(duration));
64
266
  }
65
267
  /**
66
- * Generate a list of years for the year select dropdown
268
+ * Generate a list of years for the year select dropdown, newest first. With no limits, the years are from next year
269
+ * to six years ago. With `min` or `max`, the list starts or ends at their years, and always includes the year shown.
67
270
  */
68
- yearOptionsForDate(date) {
271
+ yearOptions() {
272
+ const { min, max } = this.limits;
273
+ const thisYear = DateTime.now().year;
274
+ let first = min?.year ?? Math.min(thisYear, max?.year ?? thisYear) - 6;
275
+ let last = max?.year ?? Math.max(thisYear, min?.year ?? thisYear) + 1;
276
+ if (min || max) {
277
+ first = Math.min(first, this._displayDate.year);
278
+ last = Math.max(last, this._displayDate.year);
279
+ }
69
280
  const years = [];
70
- for (let y = date.year + 1; y >= date.year - 6; y--) {
281
+ for (let y = last; y >= first; y--) {
71
282
  years.push(y);
72
283
  }
73
284
  return years;
@@ -88,8 +299,33 @@ class FlowUiCalendar extends FlowRdfElement {
88
299
  return void 0;
89
300
  }
90
301
  }
302
+ /**
303
+ * Parse an ISO8601 interval string. In date mode, the interval is widened to whole days in the calendar's timezone:
304
+ * the start is rounded down to the start of its day and the end is rounded up to the start of the next day. An
305
+ * invalid or empty interval gives `undefined`.
306
+ */
307
+ makeRange(value) {
308
+ const wholeDays = this.displayMode === "date";
309
+ return parseDateRange(value, { zone: this.timezone, locale: this.locale, wholeDays });
310
+ }
311
+ /**
312
+ * The days to highlight in range mode: the hover preview while a range is pending, otherwise the selected range.
313
+ */
314
+ highlightedDays() {
315
+ if (this._pendingDay) {
316
+ const hover = this._hoverDate ?? this._pendingDay;
317
+ return hover < this._pendingDay ? { first: hover, last: this._pendingDay } : { first: this._pendingDay, last: hover };
318
+ }
319
+ if (this._selectedRange) {
320
+ return {
321
+ first: this._selectedRange.start.startOf("day"),
322
+ last: lastDayOfRange(this._selectedRange)
323
+ };
324
+ }
325
+ return void 0;
326
+ }
91
327
  updateValue(emitEvents = true) {
92
- const iso = this._selectedDate?.toISO();
328
+ const iso = this.isRangeMode ? this._selectedRange?.toISO() : this._selectedDate?.toISO();
93
329
  this.value = isDefined(iso) ? iso : "";
94
330
  if (emitEvents) {
95
331
  this.emit("flow-ui-input");
@@ -98,10 +334,12 @@ class FlowUiCalendar extends FlowRdfElement {
98
334
  }
99
335
  handleHeaderSelectChange(ev) {
100
336
  ev.stopPropagation();
101
- this._displayDate = this.makeDate().set({
102
- year: +this._yearSelect.value,
103
- month: +this._monthSelect.value
104
- }).startOf("month");
337
+ this._displayDate = this.clampMonth(
338
+ this.makeDate().set({
339
+ year: +this._yearSelect.value,
340
+ month: +this._monthSelect.value
341
+ }).startOf("month")
342
+ );
105
343
  }
106
344
  handleTimeChange(ev) {
107
345
  ev.stopPropagation();
@@ -111,45 +349,88 @@ class FlowUiCalendar extends FlowRdfElement {
111
349
  const target = ev.target;
112
350
  const unit = target.getAttribute("name");
113
351
  this._selectedDate = this._selectedDate.set({ [unit]: +target.value });
352
+ if (this.displayMode === "datetime") {
353
+ this._selectedDate = this.clampToLimits(this._selectedDate);
354
+ }
114
355
  this.updateValue();
115
356
  }
116
- calendarHeaderTemplate() {
357
+ /**
358
+ * Handle a change in the Start or End row of a datetime range or a time range. In a datetime range, the start time
359
+ * applies to the range start and the end time to the last day. The edited time can't pass the other one, so the range
360
+ * is never empty.
361
+ */
362
+ handleRangeTimeChange(ev, row) {
363
+ ev.stopPropagation();
364
+ const target = ev.target;
365
+ const unit = target.getAttribute("name");
366
+ const time = { ...row === "start" ? this._startTime : this._endTime, [unit]: +target.value };
367
+ const endOfDay = { hour: 24, minute: 0, second: 0 };
368
+ if (row === "start") {
369
+ this._startTime = time;
370
+ } else {
371
+ this._endTime = time.hour >= 24 ? endOfDay : time;
372
+ }
373
+ let first;
374
+ let last;
375
+ if (this.isTimeRangeMode) {
376
+ first = last = (this._selectedRange?.start ?? this.makeDate()).startOf("day");
377
+ } else if (this._selectedRange && !this._pendingDay) {
378
+ first = this._selectedRange.start.startOf("day");
379
+ last = lastDayOfRange(this._selectedRange);
380
+ } else {
381
+ return;
382
+ }
383
+ let start = this.clampToLimits(atTimeOfDay(first, this._startTime));
384
+ let end = this.clampToLimits(atTimeOfDay(last, this._endTime));
385
+ if (end <= start) {
386
+ if (row === "start") {
387
+ start = end.minus({ seconds: 1 });
388
+ } else {
389
+ end = start.plus({ seconds: 1 });
390
+ }
391
+ }
392
+ const { maxRange } = this.limits;
393
+ if (maxRange && start.plus(maxRange) < end) {
394
+ if (row === "start") {
395
+ start = end.minus(maxRange);
396
+ } else {
397
+ end = start.plus(maxRange);
398
+ }
399
+ }
400
+ const range = Interval.fromDateTimes(start, end);
401
+ if (range.isValid) {
402
+ this._selectedRange = range;
403
+ this._startTime = { hour: start.hour, minute: start.minute, second: start.second };
404
+ this._endTime = endTimeOfRange(range);
405
+ this.updateValue();
406
+ }
407
+ }
408
+ /**
409
+ * The header for the month at `index`. The first month has the month and year selects and the second month has a
410
+ * static label. The previous button is on the first month and the next button on the last. In the two-month view, a
411
+ * hidden button takes the place of each missing button, so the month label stays centered.
412
+ */
413
+ calendarHeaderTemplate(month, index) {
117
414
  const locale = isStringFull(this.locale) ? this.locale : void 0;
118
415
  const monthsShort = Info.months("short", { locale });
416
+ const isFirst = index === 0;
417
+ const isLast = index === this.monthCount - 1;
418
+ const { first, last } = this.monthLimits;
119
419
  return html`
120
420
  <div class="calendar__header">
121
421
  <flow-ui-icon-button
422
+ class=${classMap({ "calendar__header-button--hidden": !isFirst })}
423
+ ?disabled=${!!first && this._displayDate <= first}
122
424
  name="chevron-left"
123
425
  library="system"
124
426
  @click=${() => this.stepDisplayDate({ month: -1 })}
125
427
  ></flow-ui-icon-button>
126
- <flow-ui-select
127
- inline
128
- sync="width"
129
- class="calendar__month-select calendar__header-select"
130
- value=${this._displayDate.month}
131
- @flow-ui-change=${this.handleHeaderSelectChange}
132
- >
133
- ${monthsShort.map(
134
- (month, index) => html`
135
- <flow-ui-option value=${index + 1}>${month}</flow-ui-option>
136
- `
137
- )}
138
- </flow-ui-select>
139
- <flow-ui-select
140
- inline
141
- sync="width"
142
- class="calendar__year-select calendar__header-select"
143
- value=${this._displayDate.year}
144
- @flow-ui-change=${this.handleHeaderSelectChange}
145
- >
146
- ${this.yearOptionsForDate(DateTime.now()).map(
147
- (year) => html`
148
- <flow-ui-option value=${year}>${year}</flow-ui-option>
149
- `
150
- )}
151
- </flow-ui-select>
428
+ ${isFirst ? this.headerSelectsTemplate(monthsShort) : html`
429
+ <span class="calendar__header-month">${monthsShort[month.month - 1]} ${month.year}</span>
430
+ `}
152
431
  <flow-ui-icon-button
432
+ class=${classMap({ "calendar__header-button--hidden": !isLast })}
433
+ ?disabled=${!!last && this._displayDate >= last}
153
434
  name="chevron-right"
154
435
  library="system"
155
436
  @click=${() => this.stepDisplayDate({ month: 1 })}
@@ -157,6 +438,42 @@ class FlowUiCalendar extends FlowRdfElement {
157
438
  </div>
158
439
  `;
159
440
  }
441
+ headerSelectsTemplate(monthsShort) {
442
+ return html`
443
+ <flow-ui-select
444
+ inline
445
+ sync="width"
446
+ class="calendar__month-select calendar__header-select"
447
+ value=${this._displayDate.month}
448
+ @flow-ui-change=${this.handleHeaderSelectChange}
449
+ >
450
+ ${monthsShort.map(
451
+ (month, index) => html`
452
+ <flow-ui-option value=${index + 1} ?disabled=${this.isMonthDisabled(index + 1)}>${month}</flow-ui-option>
453
+ `
454
+ )}
455
+ </flow-ui-select>
456
+ <flow-ui-select
457
+ inline
458
+ sync="width"
459
+ class="calendar__year-select calendar__header-select"
460
+ value=${this._displayDate.year}
461
+ @flow-ui-change=${this.handleHeaderSelectChange}
462
+ >
463
+ ${this.yearOptions().map(
464
+ (year) => html`
465
+ <flow-ui-option value=${year}>${year}</flow-ui-option>
466
+ `
467
+ )}
468
+ </flow-ui-select>
469
+ `;
470
+ }
471
+ /** Whether a month of the year shown is outside the months the user can navigate to. */
472
+ isMonthDisabled(month) {
473
+ const { first, last } = this.monthLimits;
474
+ const date = this._displayDate.set({ month });
475
+ return !!first && date < first || !!last && date > last;
476
+ }
160
477
  dayOfWeekGridTemplate() {
161
478
  const locale = isStringFull(this.locale) ? this.locale : void 0;
162
479
  const days = Info.weekdays("narrow", { locale });
@@ -168,102 +485,162 @@ class FlowUiCalendar extends FlowRdfElement {
168
485
  )}
169
486
  `;
170
487
  }
171
- dayGridTemplate() {
172
- const today = this.makeDate();
173
- const startOfMonth = this._displayDate.startOf("month");
174
- const endOfMonth = this._displayDate.endOf("month");
488
+ /**
489
+ * The days shown in the grid for a month, including the filler days from the adjacent months that complete the
490
+ * first and last weeks. In the two-month view, the filler days between the two months are hidden, because the other
491
+ * month shows them.
492
+ */
493
+ daysForMonth(month, hideLeadingFiller = false, hideTrailingFiller = false) {
494
+ const today = this.makeDate().startOf("day");
495
+ const highlighted = this.isRangeMode ? this.highlightedDays() : void 0;
496
+ const limits = this.limits;
497
+ const startOfMonth = month.startOf("month");
498
+ const endOfMonth = month.endOf("month");
175
499
  const calendarStart = startOfMonth.startOf("week");
176
500
  const calendarEnd = endOfMonth.endOf("week");
177
- const daysInMonth = this._displayDate.daysInMonth;
178
501
  const days = [];
179
- for (let date = calendarStart; date < startOfMonth; date = date.plus({ day: 1 })) {
180
- days.push({
181
- day: date.toFormat("d"),
182
- date: date.toISO(),
183
- isFiller: true,
184
- today: date === today,
185
- selected: !!this._selectedDate && date.startOf("day").equals(this._selectedDate.startOf("day"))
186
- });
187
- }
188
- for (let day = 1; day <= daysInMonth; day++) {
189
- const date = startOfMonth.plus({ days: day - 1 }).startOf("day");
190
- days.push({
191
- day: date.toFormat("d"),
192
- date: date.toISO(),
193
- isFiller: false,
194
- today: date.startOf("day").equals(today.startOf("day")),
195
- selected: !!this._selectedDate && date.startOf("day").equals(this._selectedDate.startOf("day"))
196
- });
197
- }
198
- for (let date = endOfMonth.plus({ day: 1 }).startOf("day"); date < calendarEnd; date = date.plus({ day: 1 })) {
502
+ for (let date = calendarStart; date < calendarEnd; date = date.plus({ days: 1 }).startOf("day")) {
503
+ const column = days.length % 7;
504
+ const hidden = hideLeadingFiller && date < startOfMonth || hideTrailingFiller && date > endOfMonth;
505
+ const inRange = !hidden && !!highlighted && date >= highlighted.first && date <= highlighted.last;
199
506
  days.push({
200
507
  day: date.toFormat("d"),
201
508
  date: date.toISO(),
202
- isFiller: true,
203
- today: date.startOf("day").equals(today.startOf("day")),
204
- selected: !!this._selectedDate && date.startOf("day").equals(this._selectedDate.startOf("day"))
509
+ isFiller: date < startOfMonth || date > endOfMonth,
510
+ hidden,
511
+ today: date.equals(today),
512
+ disabled: this.isDayDisabled(date, limits),
513
+ selected: !this.isRangeMode && !!this._selectedDate && date.equals(this._selectedDate.startOf("day")),
514
+ rangeStart: inRange && +date === +highlighted.first,
515
+ rangeLast: inRange && +date === +highlighted.last,
516
+ inRange,
517
+ // The range band also has rounded ends next to the hidden days
518
+ rowStart: column === 0 || hideLeadingFiller && +date === +startOfMonth,
519
+ rowEnd: column === 6 || hideTrailingFiller && +date === +endOfMonth.startOf("day")
205
520
  });
206
521
  }
522
+ return days;
523
+ }
524
+ dayGridTemplate(month, index) {
525
+ const days = this.daysForMonth(month, index > 0, index < this.monthCount - 1);
207
526
  return html`
208
527
  ${days.map(
209
- (day) => html`
210
- <button
211
- class=${classMap({
528
+ (day) => day.hidden ? html`
529
+ <span class="calendar__day-placeholder"></span>
530
+ ` : html`
531
+ <button
532
+ class=${classMap({
212
533
  calendar__day: true,
213
534
  "calendar__day--filler": day.isFiller,
214
535
  "calendar__day--today": day.today,
215
- "calendar__day--selected": day.selected
536
+ "calendar__day--disabled": day.disabled,
537
+ "calendar__day--selected": day.selected,
538
+ "calendar__day--range-start": day.rangeStart,
539
+ "calendar__day--range-last": day.rangeLast,
540
+ "calendar__day--in-range": day.inRange,
541
+ "calendar__day--range-preview": day.inRange && !!this._pendingDay,
542
+ "calendar__day--row-start": day.rowStart,
543
+ "calendar__day--row-end": day.rowEnd
216
544
  })}
217
- value=${day.date}
218
- @click=${() => this.dateClicked(day.date)}
219
- >
220
- ${day.day}
221
- </button>
222
- `
545
+ value=${day.date}
546
+ ?disabled=${day.disabled}
547
+ @click=${() => this.dateClicked(day.date)}
548
+ @mouseenter=${() => this.dayHovered(day.date)}
549
+ @focus=${() => this.dayHovered(day.date)}
550
+ >
551
+ ${day.day}
552
+ </button>
553
+ `
223
554
  )}
224
555
  `;
225
556
  }
557
+ monthTemplate(index) {
558
+ const month = this._displayDate.plus({ months: index });
559
+ return html`
560
+ <div class="calendar__month">
561
+ ${this.calendarHeaderTemplate(month, index)}
562
+ <div class="calendar__grid" @mouseleave=${this.handleGridMouseLeave}>
563
+ ${this.dayOfWeekGridTemplate()} ${this.dayGridTemplate(month, index)}
564
+ </div>
565
+ </div>
566
+ `;
567
+ }
568
+ handleGridMouseLeave() {
569
+ if (this._pendingDay) {
570
+ this._hoverDate = void 0;
571
+ }
572
+ }
573
+ /** The hour, minute and second inputs for `time`. */
574
+ timeInputsTemplate(time, onChange, maxHour = 23) {
575
+ const size = this.size === "small" ? "small" : "medium";
576
+ return html`
577
+ <flow-ui-input
578
+ filled
579
+ type="number"
580
+ size=${size}
581
+ name="hour"
582
+ min="0"
583
+ max=${maxHour}
584
+ format="02"
585
+ class="calendar__time-input"
586
+ .value=${live(String(time.hour))}
587
+ @flow-ui-change=${onChange}
588
+ ></flow-ui-input>
589
+ <span class="calendar__time-separator">:</span>
590
+ <flow-ui-input
591
+ filled
592
+ type="number"
593
+ size=${size}
594
+ name="minute"
595
+ min="0"
596
+ max="59"
597
+ format="02"
598
+ class="calendar__time-input"
599
+ .value=${live(String(time.minute))}
600
+ @flow-ui-change=${onChange}
601
+ ></flow-ui-input>
602
+ <span class="calendar__time-separator">:</span>
603
+ <flow-ui-input
604
+ filled
605
+ type="number"
606
+ size=${size}
607
+ name="second"
608
+ min="0"
609
+ max="59"
610
+ format="02"
611
+ class="calendar__time-input"
612
+ .value=${live(String(time.second))}
613
+ @flow-ui-change=${onChange}
614
+ ></flow-ui-input>
615
+ `;
616
+ }
617
+ /**
618
+ * The time inputs: one row in single mode, or the Start and End rows of a datetime range or a time range. The Start
619
+ * and End rows are stacked, except in a datetime range in the two-month view.
620
+ */
226
621
  timeTemplate() {
622
+ if (this.isDateTimeRangeMode || this.isTimeRangeMode) {
623
+ const isSplit = this.isDateTimeRangeMode && this.monthCount === 2;
624
+ return html`
625
+ <div class=${classMap({ "calendar__time-rows": true, "calendar__time-rows--split": isSplit })}>
626
+ <div class="calendar__time-container calendar__time-container--range">
627
+ <span class="calendar__time-label">${this.localize.term("rangeStartTime")}</span>
628
+ ${this.timeInputsTemplate(this._startTime, (ev) => this.handleRangeTimeChange(ev, "start"))}
629
+ </div>
630
+ <div class="calendar__time-container calendar__time-container--range">
631
+ <span class="calendar__time-label">${this.localize.term("rangeEndTime")}</span>
632
+ ${this.timeInputsTemplate(this._endTime, (ev) => this.handleRangeTimeChange(ev, "end"), 24)}
633
+ </div>
634
+ </div>
635
+ `;
636
+ }
637
+ const date = this._selectedDate;
227
638
  return html`
228
639
  <div class="calendar__time-container">
229
- <flow-ui-input
230
- filled
231
- type="number"
232
- size=${this.size === "small" ? "small" : "medium"}
233
- name="hour"
234
- min="0"
235
- max="23"
236
- format="02"
237
- class="calendar__time-input"
238
- value=${this._selectedDate ? this._selectedDate.hour : 0}
239
- @flow-ui-change=${this.handleTimeChange}
240
- ></flow-ui-input>
241
- <span class="calendar__time-separator">:</span>
242
- <flow-ui-input
243
- filled
244
- type="number"
245
- size=${this.size === "small" ? "small" : "medium"}
246
- name="minute"
247
- min="0"
248
- max="59"
249
- format="02"
250
- class="calendar__time-input"
251
- value=${this._selectedDate ? this._selectedDate.minute : 0}
252
- @flow-ui-change=${this.handleTimeChange}
253
- ></flow-ui-input>
254
- <span class="calendar__time-separator">:</span>
255
- <flow-ui-input
256
- filled
257
- type="number"
258
- size=${this.size === "small" ? "small" : "medium"}
259
- name="second"
260
- min="0"
261
- max="59"
262
- format="02"
263
- class="calendar__time-input"
264
- value=${this._selectedDate ? this._selectedDate.second : 0}
265
- @flow-ui-change=${this.handleTimeChange}
266
- ></flow-ui-input>
640
+ ${this.timeInputsTemplate(
641
+ { hour: date?.hour ?? 0, minute: date?.minute ?? 0, second: date?.second ?? 0 },
642
+ this.handleTimeChange
643
+ )}
267
644
  </div>
268
645
  `;
269
646
  }
@@ -273,16 +650,19 @@ class FlowUiCalendar extends FlowRdfElement {
273
650
  return html`
274
651
  <div
275
652
  part="base"
653
+ @keydown=${this.handleKeyDown}
276
654
  class=${classMap({
277
655
  calendar: true,
278
656
  "calendar--small": this.size === "small",
279
657
  "calendar--medium": this.size === "medium",
280
- "calendar--large": this.size === "large"
658
+ "calendar--large": this.size === "large",
659
+ "calendar--two-months": this.monthCount === 2 && showCalendar
281
660
  })}
282
661
  >
283
662
  ${showCalendar ? html`
284
- ${this.calendarHeaderTemplate()}
285
- <div class="calendar__grid">${this.dayOfWeekGridTemplate()} ${this.dayGridTemplate()}</div>
663
+ <div class="calendar__months">
664
+ ${Array.from({ length: this.monthCount }, (_, index) => this.monthTemplate(index))}
665
+ </div>
286
666
  ` : nothing}
287
667
  ${showTime ? this.timeTemplate() : nothing}
288
668
  </div>
@@ -302,12 +682,30 @@ __decorateClass([
302
682
  __decorateClass([
303
683
  state()
304
684
  ], FlowUiCalendar.prototype, "_selectedDate", 2);
685
+ __decorateClass([
686
+ state()
687
+ ], FlowUiCalendar.prototype, "_selectedRange", 2);
688
+ __decorateClass([
689
+ state()
690
+ ], FlowUiCalendar.prototype, "_pendingDay", 2);
691
+ __decorateClass([
692
+ state()
693
+ ], FlowUiCalendar.prototype, "_hoverDate", 2);
694
+ __decorateClass([
695
+ state()
696
+ ], FlowUiCalendar.prototype, "_startTime", 2);
697
+ __decorateClass([
698
+ state()
699
+ ], FlowUiCalendar.prototype, "_endTime", 2);
305
700
  __decorateClass([
306
701
  property({ reflect: true })
307
702
  ], FlowUiCalendar.prototype, "value", 2);
308
703
  __decorateClass([
309
704
  property({ attribute: "display-mode" })
310
705
  ], FlowUiCalendar.prototype, "displayMode", 2);
706
+ __decorateClass([
707
+ property({ type: Boolean, reflect: true })
708
+ ], FlowUiCalendar.prototype, "range", 2);
311
709
  __decorateClass([
312
710
  property()
313
711
  ], FlowUiCalendar.prototype, "timezone", 2);
@@ -317,11 +715,29 @@ __decorateClass([
317
715
  __decorateClass([
318
716
  property()
319
717
  ], FlowUiCalendar.prototype, "size", 2);
718
+ __decorateClass([
719
+ property({ type: Number })
720
+ ], FlowUiCalendar.prototype, "months", 2);
721
+ __decorateClass([
722
+ property()
723
+ ], FlowUiCalendar.prototype, "min", 2);
724
+ __decorateClass([
725
+ property()
726
+ ], FlowUiCalendar.prototype, "max", 2);
727
+ __decorateClass([
728
+ property({ attribute: "max-range" })
729
+ ], FlowUiCalendar.prototype, "maxRange", 2);
320
730
  __decorateClass([
321
731
  watch("value", { waitUntilFirstUpdate: true })
322
732
  ], FlowUiCalendar.prototype, "handleValueChange", 1);
323
733
  __decorateClass([
324
734
  watch(["timezone"], { waitUntilFirstUpdate: true })
325
735
  ], FlowUiCalendar.prototype, "handleTimezoneChange", 1);
736
+ __decorateClass([
737
+ watch("range", { waitUntilFirstUpdate: true })
738
+ ], FlowUiCalendar.prototype, "handleRangeChange", 1);
739
+ __decorateClass([
740
+ watch("displayMode", { waitUntilFirstUpdate: true })
741
+ ], FlowUiCalendar.prototype, "handleDisplayModeChange", 1);
326
742
 
327
743
  export { FlowUiCalendar as default };