shadcn-ui 0.0.13 → 0.0.15

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.
@@ -0,0 +1,766 @@
1
+ // Imported from: https://github.com/airblade/stimulus-datepicker/blob/main/src/datepicker.js
2
+
3
+ import { Controller } from "@hotwired/stimulus";
4
+ import IsoDate from "utils/iso_date";
5
+ import { useClickOutside } from "stimulus-use";
6
+
7
+ // All dates are local, not UTC.
8
+ export default class UIDatePickerController extends Controller {
9
+ static targets = [
10
+ "input",
11
+ "hidden",
12
+ "toggle",
13
+ "calendar",
14
+ "month",
15
+ "year",
16
+ "prevMonth",
17
+ "today",
18
+ "nextMonth",
19
+ "days",
20
+ ];
21
+
22
+ static values = {
23
+ date: String,
24
+ month: String,
25
+ year: String,
26
+ min: String,
27
+ max: String,
28
+ isCalendarOpen: { type: Boolean, default: false },
29
+ isSelectOpen: { type: Boolean, default: false },
30
+ format: { type: String, default: "%Y-%m-%d" },
31
+ firstDayOfWeek: { type: Number, default: 1 },
32
+ dayNameLength: { type: Number, default: 2 },
33
+ allowWeekends: { type: Boolean, default: true },
34
+ monthJump: { type: String, default: "dayOfMonth" },
35
+ disallow: Array,
36
+ text: Object,
37
+ locales: { type: Array, default: ["default"] },
38
+ };
39
+
40
+ static defaultTextValue = {
41
+ underflow: "",
42
+ overflow: "",
43
+ previousMonth: "Previous month",
44
+ nextMonth: "Next month",
45
+ today: "Today",
46
+ chooseDate: "Choose Date",
47
+ changeDate: "Change Date",
48
+ };
49
+
50
+ text(key) {
51
+ return { ...this.constructor.defaultTextValue, ...this.textValue }[key];
52
+ }
53
+
54
+ connect() {
55
+ useClickOutside(this);
56
+ if (!this.hasHiddenTarget) this.addHiddenInput();
57
+ this.addInputAction();
58
+ this.addToggleAction();
59
+ this.setToggleAriaLabel();
60
+ this.dateValue = this.validate(this.inputTarget.textContent)
61
+ ? ""
62
+ : this.inputTarget.textContent;
63
+ }
64
+
65
+ disconnect() {
66
+ this.isCalendarOpenValue = false;
67
+ }
68
+
69
+ dateValueChanged(value, previousValue) {
70
+ if (!this.hasHiddenTarget) return;
71
+ const dispatchChangeEvent = value != this.hiddenTarget.value;
72
+ this.hiddenTarget.value = value;
73
+ // this.inputTarget.value = this.format(value)
74
+ this.inputTarget.textContent = value || "Pick a date";
75
+ // Trigger change event on input when user selects date from picker.
76
+ // http://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/change_event
77
+ if (dispatchChangeEvent) this.inputTarget.dispatchEvent(new Event("change"));
78
+ this.validate(value);
79
+ }
80
+
81
+ validate(dateStr) {
82
+ this.validationMessage(dateStr);
83
+ }
84
+
85
+ validationMessage(dateStr) {
86
+ if (!dateStr) return "";
87
+ const isoDate = new IsoDate(dateStr);
88
+ return this.rangeUnderflow(isoDate)
89
+ ? this.underflowMessage()
90
+ : this.rangeOverflow(isoDate)
91
+ ? this.overflowMessage()
92
+ : "";
93
+ }
94
+
95
+ underflowMessage() {
96
+ return this.text("underflow").replace("%s", this.format(this.minValue));
97
+ }
98
+
99
+ overflowMessage() {
100
+ return this.text("overflow").replace("%s", this.format(this.maxValue));
101
+ }
102
+
103
+ addHiddenInput() {
104
+ this.inputTarget.insertAdjacentHTML(
105
+ "afterend",
106
+ `
107
+ <input type="hidden"
108
+ name="${this.inputTarget.getAttribute("name")}"
109
+ value="${this.inputTarget.textContent}"
110
+ data-ui--date-picker-target="hidden"/>
111
+ `,
112
+ );
113
+ }
114
+
115
+ addInputAction() {
116
+ this.addAction(this.inputTarget, "ui--date-picker#update");
117
+ }
118
+
119
+ addToggleAction() {
120
+ if (!this.hasToggleTarget) return;
121
+
122
+ let action = "click->ui--date-picker#toggle";
123
+ if (!(this.toggleTarget instanceof HTMLButtonElement))
124
+ action += " keydown->ui--date-picker#toggle";
125
+
126
+ this.addAction(this.toggleTarget, action);
127
+ }
128
+
129
+ addAction(element, action) {
130
+ if ("action" in element.dataset) {
131
+ element.dataset.action += ` ${action}`;
132
+ } else {
133
+ element.dataset.action = action;
134
+ }
135
+ }
136
+
137
+ setToggleAriaLabel(value = this.text("chooseDate")) {
138
+ if (!this.hasToggleTarget) return;
139
+ this.toggleTarget.setAttribute("aria-label", value);
140
+ }
141
+
142
+ update() {
143
+ const dateStr = this.parse(this.inputTarget.value);
144
+ if (dateStr != "") this.dateValue = dateStr;
145
+ }
146
+
147
+ toggle(event) {
148
+ event.preventDefault();
149
+ event.stopPropagation();
150
+ if (event.type == "keydown" && ![" ", "Enter"].includes(event.key)) return;
151
+ this.hasCalendarTarget ? this.close(true) : this.open(true);
152
+ }
153
+
154
+ close(animate) {
155
+ if (animate) {
156
+ this.calendarTarget.classList.add("fade-out");
157
+ if (this.hasCssAnimation(this.calendarTarget)) {
158
+ this.calendarTarget.onanimationend = (e) => e.target.remove();
159
+ } else {
160
+ this.calendarTarget.remove();
161
+ }
162
+ } else {
163
+ this.calendarTarget.remove();
164
+ }
165
+ this.isCalendarOpenValue = false;
166
+ this.toggleTarget.focus();
167
+ }
168
+
169
+ open(animate, isoDate = this.initialIsoDate()) {
170
+ this.isCalendarOpenValue = true;
171
+ this.render(isoDate, animate);
172
+ this.focusDate(isoDate);
173
+ }
174
+
175
+ // Returns the date to focus on initially. This is `dateValue` if given
176
+ // or today. Whichever is used, it is clamped to `minValue` and/or `maxValue`
177
+ // dates if given.
178
+ initialIsoDate() {
179
+ return this.clamp(new IsoDate(this.dateValue));
180
+ }
181
+
182
+ clamp(isoDate) {
183
+ return this.rangeUnderflow(isoDate)
184
+ ? new IsoDate(this.minValue)
185
+ : this.rangeOverflow(isoDate)
186
+ ? new IsoDate(this.maxValue)
187
+ : isoDate;
188
+ }
189
+
190
+ rangeUnderflow(isoDate) {
191
+ return this.hasMinValue && isoDate.before(new IsoDate(this.minValue));
192
+ }
193
+
194
+ rangeOverflow(isoDate) {
195
+ return this.hasMaxValue && isoDate.after(new IsoDate(this.maxValue));
196
+ }
197
+
198
+ isOutOfRange(isoDate) {
199
+ return this.rangeUnderflow(isoDate) || this.rangeOverflow(isoDate);
200
+ }
201
+
202
+ clickOutside(event) {
203
+ if (this.isCalendarOpenValue) event.preventDefault();
204
+ if (!this.isCalendarOpenValue) return;
205
+ if (event.target.closest('[data-ui--date-picker-target="calendar"]')) return;
206
+ if (this.isSelectOpenValue) return;
207
+ this.close(true);
208
+ }
209
+
210
+ // To track option is clicked
211
+ clickOption() {
212
+ this.isSelectOpenValue = true;
213
+ }
214
+
215
+ monthSelect(event) {
216
+ this.monthValue = event.target.textContent;
217
+ this.redraw();
218
+ setTimeout(() => {
219
+ this.isSelectOpenValue = false;
220
+ }, 500);
221
+ }
222
+
223
+ yearSelect(event) {
224
+ this.yearValue = event.target.textContent;
225
+ this.redraw();
226
+ setTimeout(() => {
227
+ this.isSelectOpenValue = false;
228
+ }, 500);
229
+ }
230
+
231
+ redraw() {
232
+ const isoDate = this.dateFromMonthYearSelectsAndDayGrid();
233
+ this.close(false);
234
+ this.open(false, isoDate);
235
+ }
236
+
237
+ gotoPrevMonth() {
238
+ const isoDate = this.dateFromMonthYearSelectsAndDayGrid();
239
+ const previousMonthDate = isoDate.previousMonth(this.monthJumpValue == "dayOfMonth");
240
+ this.monthValue = previousMonthDate.mm;
241
+ this.yearValue = previousMonthDate.yyyy;
242
+ this.close(false);
243
+ this.open(false, previousMonthDate);
244
+ this.prevMonthTarget.focus();
245
+ }
246
+
247
+ gotoNextMonth() {
248
+ const isoDate = this.dateFromMonthYearSelectsAndDayGrid();
249
+ const nextMonthDate = isoDate.nextMonth(this.monthJumpValue == "dayOfMonth");
250
+ this.monthValue = nextMonthDate.mm;
251
+ this.yearValue = nextMonthDate.yyyy;
252
+ this.close(false);
253
+ this.open(false, nextMonthDate);
254
+ this.nextMonthTarget.focus();
255
+ }
256
+
257
+ gotoToday() {
258
+ this.close(false);
259
+ this.open(false, new IsoDate());
260
+ this.todayTarget.focus();
261
+ }
262
+
263
+ // Returns a date where the month and year come from the dropdowns
264
+ // and the day of the month from the grid.
265
+ // @return [IsoDate]
266
+ dateFromMonthYearSelectsAndDayGrid() {
267
+ const isoDate = this.initialIsoDate();
268
+ this.yearValue ||= isoDate.yyyy;
269
+ this.monthValue ||= isoDate.mm;
270
+
271
+ let day = this.daysTarget.querySelector('button[tabindex="0"] time').textContent;
272
+
273
+ const daysInMonth = IsoDate.daysInMonth(+this.monthValue, +this.yearValue);
274
+ if (day > daysInMonth) day = daysInMonth;
275
+
276
+ return new IsoDate(this.yearValue, this.monthValue, day);
277
+ }
278
+
279
+ // Generates the HTML for the calendar and inserts it into the DOM.
280
+ //
281
+ // Does not focus the given date.
282
+ //
283
+ // @param isoDate [IsoDate] the date of interest
284
+ render(isoDate, animate) {
285
+ const cal = `<div class="absolute z-10 p-3 rounded-md border bg-white" data-ui--date-picker-target="calendar" data-action="keydown->ui--date-picker#key" role="dialog" aria-modal="true" aria-label="${this.text(
286
+ "chooseDate",
287
+ )}">
288
+ <div class="flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0 overscroll-contain">
289
+ <div class="space-y-4">
290
+ <div class="flex justify-center relative items-center">
291
+ <div class="flex items-center">
292
+ <button data-ui--date-picker-target="prevMonth" data-action="ui--date-picker#gotoPrevMonth" title="${this.text(
293
+ "previousMonth",
294
+ )}" aria-label="${this.text("previousMonth")}"
295
+ class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input hover:bg-accent hover:text-accent-foreground h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-1" type="button">
296
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-left h-4 w-4"><path d="m15 18-6-6 6-6"></path></svg>
297
+ </button>
298
+ </div>
299
+ <div class="flex">
300
+ <div data-controller="ui--select" data-ui--select-target="wrapper" data-action="keydown->ui--select#key keydown.enter->ui--date-picker#clickOption click->ui--date-picker#clickOption change->ui--date-picker#monthSelect">
301
+ <div class="relative">
302
+ <button data-action="ui--select#toggle" data-ui--date-picker-target="month" class="py-2 px-3 rounded-md flex items-center justify-between w-full text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
303
+ <span data-ui--select-target="value">${isoDate.getMonthName()}</span>
304
+ <svg class="w-4 h-4 mt-1 stroke-slate-400" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
305
+ </button>
306
+ <div data-ui--select-target="menu" data-action="click->ui--select#select"
307
+ class="absolute z-10 bg-white rounded-md shadow-lg mt-2 w-30 py-1 hidden overflow-auto overscroll-contain max-h-60">
308
+ ${this.monthOptions(+isoDate.mm)}
309
+ </div>
310
+ </div>
311
+ </div>
312
+ </div>
313
+ <div class="flex">
314
+ <div data-controller="ui--select" data-ui--select-target="wrapper" data-action="keydown->ui--select#key keydown.enter->ui--date-picker#clickOption click->ui--date-picker#clickOption change->ui--date-picker#yearSelect">
315
+ <div class="relative">
316
+ <button data-action="ui--select#toggle" data-ui--date-picker-target="year" class="py-2 px-3 rounded-md flex items-center justify-between w-full text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
317
+ <span data-ui--select-target="value">${isoDate.yyyy}</span>
318
+ <svg class="w-4 h-4 mt-1 stroke-slate-400" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
319
+ </button>
320
+ <div data-ui--select-target="menu" data-action="click->ui--select#select"
321
+ class="absolute z-10 bg-white rounded-md shadow-lg mt-2 w-20 py-1 hidden overflow-auto overscroll-contain max-h-60 scrollbar:!h-1.5 scrollbar:bg-transparent scrollbar-track:!bg-slate-100 scrollbar-thumb:!rounded scrollbar-thumb:!bg-slate-300 scrollbar-track:!rounded">
322
+ ${this.yearOptions(+isoDate.yyyy)}
323
+ </div>
324
+ </div>
325
+ </div>
326
+ </div>
327
+ <div class="flex items-center">
328
+ <button data-ui--date-picker-target="nextMonth" data-action="ui--date-picker#gotoNextMonth" title="${this.text(
329
+ "nextMonth",
330
+ )}" aria-label="${this.text("nextMonth")}"
331
+ class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input hover:bg-accent hover:text-accent-foreground h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-1" type="button">
332
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right h-4 w-4"><path d="m9 18 6-6-6-6"></path></svg>
333
+ </button>
334
+ </div>
335
+ </div>
336
+ <div class="sdp-days-of-week grid grid-cols-7 text-center">
337
+ ${this.daysOfWeek()}
338
+ </div>
339
+ <div class="sdp-days grid grid-cols-7 text-center" role="grid" data-ui--date-picker-target="days" data-action="click->ui--date-picker#pick">
340
+ ${this.days(isoDate)}
341
+ </div>
342
+ <div class="flex items-center justify-center">
343
+ <button data-ui--date-picker-target="today" data-action="ui--date-picker#gotoToday"
344
+ class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input hover:bg-accent hover:text-accent-foreground bg-transparent opacity-50 hover:opacity-100 py-1 px-2"
345
+ title="${this.text("today")}" aria-label="${this.text("today")}">
346
+ Today
347
+ </button>
348
+ </div>
349
+ </div>
350
+ </div>
351
+ </div>`;
352
+ this.element.insertAdjacentHTML("beforeend", cal);
353
+ }
354
+
355
+ pick(event) {
356
+ event.preventDefault();
357
+
358
+ let button, time;
359
+ switch (event.target.constructor) {
360
+ case HTMLTimeElement:
361
+ time = event.target;
362
+ button = time.parentElement;
363
+ break;
364
+ case HTMLButtonElement:
365
+ button = event.target;
366
+ time = button.children[0];
367
+ break;
368
+ default:
369
+ return;
370
+ }
371
+
372
+ if (button.hasAttribute("aria-disabled")) return;
373
+ const dateStr = time.getAttribute("datetime");
374
+ this.selectDate(new IsoDate(dateStr));
375
+ }
376
+
377
+ key(event) {
378
+ // if(this.isSelectOpenValue){return}
379
+ switch (event.key) {
380
+ case "Escape":
381
+ this.close(true);
382
+ return;
383
+ case "Tab":
384
+ if (event.shiftKey) {
385
+ if (document.activeElement == this.firstTabStop()) {
386
+ event.preventDefault();
387
+ this.lastTabStop().focus();
388
+ }
389
+ } else {
390
+ if (document.activeElement == this.lastTabStop()) {
391
+ event.preventDefault();
392
+ this.firstTabStop().focus();
393
+ }
394
+ }
395
+ return;
396
+ }
397
+
398
+ const button = event.target;
399
+ if (!this.daysTarget.contains(button)) return;
400
+
401
+ const dateStr = button.children[0].getAttribute("datetime");
402
+ const isoDate = new IsoDate(dateStr);
403
+
404
+ switch (event.key) {
405
+ case "Enter":
406
+ case " ":
407
+ event.preventDefault();
408
+ if (!button.hasAttribute("aria-disabled")) this.selectDate(isoDate);
409
+ break;
410
+ case "ArrowUp":
411
+ case "k":
412
+ this.focusDate(isoDate.previousWeek());
413
+ break;
414
+ case "ArrowDown":
415
+ case "j":
416
+ this.focusDate(isoDate.nextWeek());
417
+ break;
418
+ case "ArrowLeft":
419
+ case "h":
420
+ this.focusDate(isoDate.previousDay());
421
+ break;
422
+ case "ArrowRight":
423
+ case "l":
424
+ this.focusDate(isoDate.nextDay());
425
+ break;
426
+ case "Home":
427
+ case "0":
428
+ case "^":
429
+ this.focusDate(isoDate.firstDayOfWeek(this.firstDayOfWeekValue));
430
+ break;
431
+ case "End":
432
+ case "$":
433
+ this.focusDate(isoDate.lastDayOfWeek(this.firstDayOfWeekValue));
434
+ break;
435
+ case "PageUp":
436
+ event.shiftKey
437
+ ? this.focusDate(isoDate.previousYear())
438
+ : this.focusDate(isoDate.previousMonth(this.monthJumpIsDayOfMonth()));
439
+ break;
440
+ case "PageDown":
441
+ event.shiftKey
442
+ ? this.focusDate(isoDate.nextYear())
443
+ : this.focusDate(isoDate.nextMonth(this.monthJumpIsDayOfMonth()));
444
+ break;
445
+ case "b":
446
+ this.focusDate(isoDate.previousMonth(this.monthJumpIsDayOfMonth()));
447
+ break;
448
+ case "B":
449
+ this.focusDate(isoDate.previousYear());
450
+ break;
451
+ case "w":
452
+ this.focusDate(isoDate.nextMonth(this.monthJumpIsDayOfMonth()));
453
+ break;
454
+ case "W":
455
+ this.focusDate(isoDate.nextYear());
456
+ break;
457
+ }
458
+ }
459
+
460
+ firstTabStop() {
461
+ return this.prevMonthTarget;
462
+ }
463
+
464
+ lastTabStop() {
465
+ return this.todayTarget;
466
+ }
467
+
468
+ monthJumpIsDayOfMonth() {
469
+ return this.monthJumpValue == "dayOfMonth";
470
+ }
471
+
472
+ // @param isoDate [isoDate] the date to select
473
+ selectDate(isoDate) {
474
+ if (this.isSelectOpenValue) return;
475
+
476
+ this.close(true);
477
+ this.toggleTarget.focus();
478
+ this.dateValue = isoDate.toString();
479
+ }
480
+
481
+ // Focuses the given date in the calendar.
482
+ // If the date is not visible because it is in the hidden part of the previous or
483
+ // next month, the calendar is updated to show the corresponding month.
484
+ //
485
+ // @param isoDate [IsoDate] the date to focus on in the calendar
486
+ focusDate(isoDate) {
487
+ const time = this.daysTarget.querySelectorAll(`time[datetime="${isoDate.toString()}"]`)[0];
488
+
489
+ if (!time) {
490
+ const leadingDatetime = this.daysTarget.querySelectorAll("time")[0].getAttribute("datetime");
491
+ if (isoDate.before(new IsoDate(leadingDatetime))) {
492
+ this.gotoPrevMonth();
493
+ } else {
494
+ this.gotoNextMonth();
495
+ }
496
+ this.focusDate(isoDate);
497
+ return;
498
+ }
499
+
500
+ const currentFocus = this.daysTarget.querySelectorAll('button[tabindex="0"]')[0];
501
+ if (currentFocus) currentFocus.setAttribute("tabindex", -1);
502
+
503
+ const button = time.parentElement;
504
+ button.setAttribute("tabindex", 0);
505
+ button.focus();
506
+
507
+ if (!button.hasAttribute("aria-disabled")) {
508
+ this.setToggleAriaLabel(`${this.text("changeDate")}, ${this.format(isoDate.toString())}`);
509
+ }
510
+ }
511
+
512
+ // @param selected [Number] the selected month (January is 1)
513
+ monthOptions(selected) {
514
+ const klass = "hover:bg-gray-100 cursor-pointer py-2 px-4";
515
+ return this.monthNames("long")
516
+ .map(
517
+ (name, i) =>
518
+ `<div class="${klass}" value="${i + 1}" ${
519
+ i + 1 == selected ? "selected" : ""
520
+ }>${name}</div>`,
521
+ )
522
+ .join("");
523
+ }
524
+
525
+ // @param selected [Number] the selected year
526
+ yearOptions(selected) {
527
+ const years = [];
528
+ const extent = 10;
529
+ const klass = "hover:bg-gray-100 cursor-pointer py-2 px-4";
530
+
531
+ for (let y = selected - extent; y <= selected + extent; y++) years.push(y);
532
+ return years
533
+ .map(
534
+ (year) =>
535
+ `<div class="${klass}" value=${year} ${year == selected ? "selected" : ""}>${year}</div>`,
536
+ )
537
+ .join("");
538
+ }
539
+
540
+ daysOfWeek() {
541
+ return this.dayNames("long")
542
+ .map(
543
+ (
544
+ name,
545
+ ) => `<div scope="col" class="text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]" aria-label="${name}">
546
+ ${name.slice(0, this.dayNameLengthValue)} </div>`,
547
+ )
548
+ .join("");
549
+ }
550
+
551
+ // Generates the day grid for the given date's month.
552
+ // The end of the previous month and the start of the next month
553
+ // are shown if there is space in the grid.
554
+ //
555
+ // Does not focus on the given date.
556
+ //
557
+ // @param isoDate [IsoDate] the month of interest
558
+ // @return [String] HTML for the day grid
559
+ days(isoDate) {
560
+ let days = [];
561
+ const selected = new IsoDate(this.dateValue);
562
+ let date = isoDate.setDayOfMonth(1).firstDayOfWeek(this.firstDayOfWeekValue);
563
+
564
+ while (true) {
565
+ const isPreviousMonth = date.mm != isoDate.mm && date.before(isoDate);
566
+ const isNextMonth = date.mm != isoDate.mm && date.after(isoDate);
567
+
568
+ if (isNextMonth && date.isFirstDayOfWeek(this.firstDayOfWeekValue)) break;
569
+
570
+ const outsideMonthClass =
571
+ "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30";
572
+ const klass = this.classAttribute(
573
+ "sdp-day inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-9 w-9 p-0 font-normal aria-selected:opacity-100 text-accent-foreground",
574
+ isPreviousMonth ? outsideMonthClass : "text-accent-foreground",
575
+ isNextMonth ? outsideMonthClass : "text-accent-foreground",
576
+ date.isToday() ? "sdp-today bg-accent" : "",
577
+ date.isWeekend() ? "sdp-weekend" : "",
578
+ date.equals(selected)
579
+ ? "sdp-selected bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground"
580
+ : "",
581
+ );
582
+
583
+ days.push(`
584
+ <button type="button"
585
+ tabindex="-1"
586
+ ${klass}
587
+ ${date.equals(selected) ? 'aria-selected="true"' : ""}
588
+ ${this.isDisabled(date) ? 'aria-disabled="true"' : ""}
589
+ >
590
+ <time datetime="${date.toString()}">${+date.dd}</time>
591
+ </button>
592
+ `);
593
+ date = date.nextDay();
594
+ }
595
+
596
+ return days.join("");
597
+ }
598
+
599
+ classAttribute(...classes) {
600
+ const presentClasses = classes.filter((c) => c);
601
+ if (presentClasses.length == 0) return "";
602
+ return `class="${presentClasses.join(" ")}"`;
603
+ }
604
+
605
+ isDisabled(isoDate) {
606
+ return (
607
+ this.isOutOfRange(isoDate) ||
608
+ (isoDate.isWeekend() && !this.allowWeekendsValue) ||
609
+ this.disallowValue.includes(isoDate.toString())
610
+ );
611
+ }
612
+
613
+ // Formats an ISO8601 date, using the `format` value, for display to the user.
614
+ // Returns an empty string if `str` cannot be formatted.
615
+ //
616
+ // @param str [String] a date in YYYY-MM-DD format
617
+ // @return [String] the date in a user-facing format, or an empty string if the
618
+ // given date cannot be formatted
619
+ format(str) {
620
+ if (!IsoDate.isValidStr(str)) return "";
621
+
622
+ const [yyyy, mm, dd] = str.split("-");
623
+
624
+ return this.formatValue
625
+ .replace("%d", dd)
626
+ .replace("%-d", +dd)
627
+ .replace("%m", this.zeroPad(mm))
628
+ .replace("%-m", +mm)
629
+ .replace("%B", this.localisedMonth(mm, "long"))
630
+ .replace("%b", this.localisedMonth(mm, "short"))
631
+ .replace("%Y", yyyy)
632
+ .replace("%y", +yyyy % 100);
633
+ }
634
+
635
+ // Returns a two-digit zero-padded string.
636
+ zeroPad(num) {
637
+ return num.toString().padStart(2, "0");
638
+ }
639
+
640
+ // Parses a date from the user, using the `format` value, into an ISO8601 date.
641
+ // Returns an empty string if `str` cannot be parsed.
642
+ //
643
+ // @param str [String] a user-facing date, e.g. 19/03/2022
644
+ // @return [String] the date in ISO8601 format, e.g. 2022-03-19; or an empty string
645
+ // if the given date cannot be parsed
646
+ parse(str) {
647
+ const directives = {
648
+ d: [
649
+ "\\d{2}",
650
+ function (match) {
651
+ this.day = +match;
652
+ },
653
+ ],
654
+ "-d": [
655
+ "\\d{1,2}",
656
+ function (match) {
657
+ this.day = +match;
658
+ },
659
+ ],
660
+ m: [
661
+ "\\d{2}",
662
+ function (match) {
663
+ this.month = +match;
664
+ },
665
+ ],
666
+ "-m": [
667
+ "\\d{1,2}",
668
+ function (match) {
669
+ this.month = +match;
670
+ },
671
+ ],
672
+ B: [
673
+ "\\w+",
674
+ function (match, controller) {
675
+ this.month = controller.monthNumber(match, "long");
676
+ },
677
+ ],
678
+ b: [
679
+ "\\w{3}",
680
+ function (match, controller) {
681
+ this.month = controller.monthNumber(match, "short");
682
+ },
683
+ ],
684
+ Y: [
685
+ "\\d{4}",
686
+ function (match) {
687
+ this.year = +match;
688
+ },
689
+ ],
690
+ y: [
691
+ "\\d{2}",
692
+ function (match) {
693
+ this.year = 2000 + +match;
694
+ },
695
+ ],
696
+ };
697
+ const funcs = [];
698
+ const re = new RegExp(
699
+ this.formatValue.replace(/%(d|-d|m|-m|B|b|Y|y)/g, function (_, p) {
700
+ const directive = directives[p];
701
+ funcs.push(directive[1]);
702
+ return `(${directive[0]})`;
703
+ }),
704
+ );
705
+ const matches = str.match(re);
706
+ if (!matches) return "";
707
+
708
+ const parts = {};
709
+ for (let i = 0, len = funcs.length; i < len; i++) {
710
+ funcs[i].call(parts, matches[i + 1], this);
711
+ }
712
+
713
+ if (!IsoDate.isValidDate(parts.year, parts.month, parts.day)) return "";
714
+ return new IsoDate(parts.year, parts.month, parts.day).toString();
715
+ }
716
+
717
+ // Returns the name of the month in the configured locale.
718
+ //
719
+ // @param month [Number] the month number (January is 1)
720
+ // @param monthFormat [String] "long" (January) | "short" (Jan)
721
+ // @return [String] the localised month name
722
+ localisedMonth(month, monthFormat) {
723
+ // Use the middle of the month to avoid timezone edge cases
724
+ return new Date(`2022-${month}-15`).toLocaleString(this.localesValue, { month: monthFormat });
725
+ }
726
+
727
+ // Returns the number of the month (January is 1).
728
+ //
729
+ // @param name [String] the name of the month in the current locale (e.g. January or Jan)
730
+ // @param monthFormat [String] "long" (January) | "short" (Jan)
731
+ // @return [Number] the number of the month, or 0 if name is not recognised
732
+ monthNumber(name, monthFormat) {
733
+ return this.monthNames(monthFormat).findIndex((m) => name.includes(m)) + 1;
734
+ }
735
+
736
+ // Returns the month names in the configured locale.
737
+ //
738
+ // @param format [String] "long" (January) | "short" (Jan)
739
+ // @return [Array] localised month names
740
+ monthNames(format) {
741
+ const formatter = new Intl.DateTimeFormat(this.localesValue, { month: format });
742
+ return ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"].map((mm) =>
743
+ // Use the middle of the month to avoid timezone edge cases
744
+ formatter.format(new Date(`2022-${mm}-15`)),
745
+ );
746
+ }
747
+
748
+ // Returns the day names in the configured locale, starting with the
749
+ // firstDayOfTheWeekValue.
750
+ //
751
+ // @param format [String] "long" (Monday) | "short" (Mon) | "narrow" (M)
752
+ // @return [Array] localised day names
753
+ dayNames(format) {
754
+ const formatter = new Intl.DateTimeFormat(this.localesValue, { weekday: format });
755
+ const names = [];
756
+ // Ensure date in month is two digits. 2022-04-10 is a Sunday
757
+ for (let i = this.firstDayOfWeekValue + 10, n = i + 7; i < n; i++) {
758
+ names.push(formatter.format(new Date(`2022-04-${i}T00:00:00`)));
759
+ }
760
+ return names;
761
+ }
762
+
763
+ hasCssAnimation(el) {
764
+ return window.getComputedStyle(el).getPropertyValue("animation-name") !== "none";
765
+ }
766
+ }