rails-datepicker 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE.txt +21 -0
- data/README.md +166 -0
- data/app/assets/stylesheets/rails_datepicker.css +98 -0
- data/app/javascript/controllers/rails_datepicker_controller.js +412 -0
- data/app/javascript/rails-datepicker/calendar.js +236 -0
- data/app/javascript/rails-datepicker/index.js +1 -0
- data/config/importmap.rb +2 -0
- data/config/locales/rails_datepicker.am.yml +12 -0
- data/config/locales/rails_datepicker.en.yml +12 -0
- data/lib/generators/rails_datepicker/install_generator.rb +87 -0
- data/lib/generators/rails_datepicker/templates/initializer.rb.tt +9 -0
- data/lib/rails-datepicker.rb +4 -0
- data/lib/rails_datepicker/calendar.rb +150 -0
- data/lib/rails_datepicker/configuration.rb +55 -0
- data/lib/rails_datepicker/engine.rb +31 -0
- data/lib/rails_datepicker/form_helper.rb +267 -0
- data/lib/rails_datepicker/simple_form_input.rb +17 -0
- data/lib/rails_datepicker/version.rb +5 -0
- data/lib/rails_datepicker.rb +25 -0
- data/package.json +22 -0
- data/test/application_system_test_case.rb +11 -0
- data/test/dummy/app/controllers/application_controller.rb +4 -0
- data/test/dummy/app/controllers/students_controller.rb +27 -0
- data/test/dummy/app/javascript/application.js +6 -0
- data/test/dummy/app/models/application_record.rb +5 -0
- data/test/dummy/app/models/student.rb +5 -0
- data/test/dummy/app/views/layouts/application.html.erb +10 -0
- data/test/dummy/app/views/students/frame.html.erb +8 -0
- data/test/dummy/app/views/students/new.html.erb +21 -0
- data/test/dummy/config/application.rb +16 -0
- data/test/dummy/config/boot.rb +2 -0
- data/test/dummy/config/cable.yml +2 -0
- data/test/dummy/config/database.yml +3 -0
- data/test/dummy/config/environment.rb +4 -0
- data/test/dummy/config/environments/test.rb +11 -0
- data/test/dummy/config/importmap.rb +5 -0
- data/test/dummy/config/routes.rb +7 -0
- data/test/dummy/db/schema.rb +7 -0
- data/test/fixtures/tailwind.css +2 -0
- data/test/generators/install_generator_test.rb +53 -0
- data/test/helpers/form_helper_test.rb +77 -0
- data/test/integration/form_submission_test.rb +22 -0
- data/test/javascript/calendar_test.js +56 -0
- data/test/rails_datepicker/calendar_test.rb +57 -0
- data/test/rails_datepicker/configuration_test.rb +39 -0
- data/test/simple_form_input_test.rb +20 -0
- data/test/system/datepicker_test.rb +93 -0
- data/test/test_helper.rb +9 -0
- metadata +253 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import {
|
|
3
|
+
MONTHS, WEEKDAYS, addDays, calendarParts, ethiopianToGregorian,
|
|
4
|
+
formatDate, labels, monthLength, parseFormatted, parseIso,
|
|
5
|
+
partsToGregorian, shiftMonth, toIso
|
|
6
|
+
} from "rails-datepicker/calendar"
|
|
7
|
+
|
|
8
|
+
export default class extends Controller {
|
|
9
|
+
static targets = ["input", "canonical", "trigger", "popover", "weekdays", "grid", "monthSelect", "yearSelect", "calendarToggle", "localeToggle"]
|
|
10
|
+
static values = {
|
|
11
|
+
calendar: { type: String, default: "ethiopian" },
|
|
12
|
+
displayCalendar: String,
|
|
13
|
+
locale: { type: String, default: "am" },
|
|
14
|
+
format: { type: String, default: "YYYY-MM-DD" },
|
|
15
|
+
theme: { type: String, default: "auto" },
|
|
16
|
+
weekStart: { type: String, default: "sunday" },
|
|
17
|
+
ethiopicNumerals: { type: Boolean, default: false },
|
|
18
|
+
inline: { type: Boolean, default: false },
|
|
19
|
+
min: String,
|
|
20
|
+
max: String,
|
|
21
|
+
disabledDates: { type: Array, default: [] },
|
|
22
|
+
yearsBefore: { type: Number, default: 100 },
|
|
23
|
+
yearsAfter: { type: Number, default: 20 },
|
|
24
|
+
selected: String
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
initialize() {
|
|
28
|
+
this.handleOutside = this.handleOutside.bind(this)
|
|
29
|
+
this.reposition = this.reposition.bind(this)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
connect() {
|
|
33
|
+
this.disabledSet = new Set(this.disabledDatesValue)
|
|
34
|
+
this.selectedDate = this.safeParseIso(this.canonicalTarget.value || this.selectedValue)
|
|
35
|
+
this.viewDate = this.selectedDate || this.todayDate()
|
|
36
|
+
this.opened = this.inlineValue
|
|
37
|
+
this.render()
|
|
38
|
+
document.addEventListener("pointerdown", this.handleOutside)
|
|
39
|
+
window.addEventListener("resize", this.reposition)
|
|
40
|
+
window.addEventListener("scroll", this.reposition, true)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
disconnect() {
|
|
44
|
+
document.removeEventListener("pointerdown", this.handleOutside)
|
|
45
|
+
window.removeEventListener("resize", this.reposition)
|
|
46
|
+
window.removeEventListener("scroll", this.reposition, true)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
toggle(event) {
|
|
50
|
+
event.preventDefault()
|
|
51
|
+
this.opened ? this.close() : this.open()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
open() {
|
|
55
|
+
if (this.inlineValue || this.inputTarget.disabled) return
|
|
56
|
+
this.opened = true
|
|
57
|
+
this.popoverTarget.hidden = false
|
|
58
|
+
this.inputTarget.setAttribute("aria-expanded", "true")
|
|
59
|
+
this.render()
|
|
60
|
+
requestAnimationFrame(() => {
|
|
61
|
+
this.positionPopover()
|
|
62
|
+
this.focusPreferredDay()
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
close({ restoreFocus = false } = {}) {
|
|
67
|
+
if (this.inlineValue) return
|
|
68
|
+
this.opened = false
|
|
69
|
+
this.popoverTarget.hidden = true
|
|
70
|
+
this.inputTarget.setAttribute("aria-expanded", "false")
|
|
71
|
+
if (restoreFocus) (this.hasTriggerTarget ? this.triggerTarget : this.inputTarget).focus()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
selectDate(event) {
|
|
75
|
+
event.preventDefault()
|
|
76
|
+
const date = this.safeParseIso(event.currentTarget.dataset.iso)
|
|
77
|
+
if (this.inputTarget.disabled || !date || this.restricted(date)) return
|
|
78
|
+
this.commit(date)
|
|
79
|
+
this.close({ restoreFocus: true })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
switchCalendar(event) {
|
|
83
|
+
event.preventDefault()
|
|
84
|
+
if (this.inputTarget.disabled) return
|
|
85
|
+
const anchor = partsToGregorian({ ...calendarParts(this.viewDate, this.calendarValue), day: 1 }, this.calendarValue)
|
|
86
|
+
this.calendarValue = this.calendarValue === "ethiopian" ? "gregorian" : "ethiopian"
|
|
87
|
+
this.viewDate = anchor
|
|
88
|
+
if (this.selectedDate) this.inputTarget.value = this.formatSelection(this.selectedDate)
|
|
89
|
+
this.render()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
switchLocale(event) {
|
|
93
|
+
event.preventDefault()
|
|
94
|
+
if (this.inputTarget.disabled) return
|
|
95
|
+
this.localeValue = this.localeValue === "am" ? "en" : "am"
|
|
96
|
+
this.render()
|
|
97
|
+
if (this.inputTarget.validationMessage) this.validate()
|
|
98
|
+
this.reposition()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
formatSelection(date) {
|
|
102
|
+
return formatDate(date, this.formatValue, this.displayCalendarValue || this.calendarValue, this.ethiopicNumeralsValue)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
previousMonth() { this.navigateMonths(-1) }
|
|
106
|
+
nextMonth() { this.navigateMonths(1) }
|
|
107
|
+
previousYear() { this.navigateYears(-1) }
|
|
108
|
+
nextYear() { this.navigateYears(1) }
|
|
109
|
+
|
|
110
|
+
selectMonth(event) {
|
|
111
|
+
const parts = calendarParts(this.viewDate, this.calendarValue)
|
|
112
|
+
parts.month = Number(event.currentTarget.value)
|
|
113
|
+
parts.day = 1
|
|
114
|
+
this.viewDate = partsToGregorian(parts, this.calendarValue)
|
|
115
|
+
this.render()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
selectYear(event) {
|
|
119
|
+
const parts = calendarParts(this.viewDate, this.calendarValue)
|
|
120
|
+
parts.year = Number(event.currentTarget.value)
|
|
121
|
+
parts.day = 1
|
|
122
|
+
this.viewDate = partsToGregorian(parts, this.calendarValue)
|
|
123
|
+
this.render()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
today(event) {
|
|
127
|
+
event.preventDefault()
|
|
128
|
+
if (this.inputTarget.disabled) return
|
|
129
|
+
const date = this.todayDate()
|
|
130
|
+
if (this.restricted(date)) return
|
|
131
|
+
this.commit(date)
|
|
132
|
+
this.close({ restoreFocus: true })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
clear(event) {
|
|
136
|
+
event.preventDefault()
|
|
137
|
+
if (this.inputTarget.disabled) return
|
|
138
|
+
|
|
139
|
+
this.selectedDate = null
|
|
140
|
+
this.selectedValue = ""
|
|
141
|
+
this.canonicalTarget.value = ""
|
|
142
|
+
this.inputTarget.value = ""
|
|
143
|
+
this.setValidity("")
|
|
144
|
+
this.dispatchChanges()
|
|
145
|
+
this.render()
|
|
146
|
+
this.close({ restoreFocus: true })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
input() {
|
|
150
|
+
this.setValidity("")
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
validate() {
|
|
154
|
+
const value = this.inputTarget.value.trim()
|
|
155
|
+
if (!value) {
|
|
156
|
+
if (this.inputTarget.required) this.setValidity(labels(this.localeValue).invalid)
|
|
157
|
+
else this.clearWithoutEvent()
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const date = parseFormatted(value, this.formatValue, this.displayCalendarValue || this.calendarValue)
|
|
162
|
+
if (this.restricted(date)) throw new RangeError(labels(this.localeValue).unavailable)
|
|
163
|
+
this.commit(date)
|
|
164
|
+
} catch (error) {
|
|
165
|
+
this.setValidity(error.message === labels(this.localeValue).unavailable ? error.message : labels(this.localeValue).invalid)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
inputKeydown(event) {
|
|
170
|
+
if (event.key === "ArrowDown" || (event.altKey && event.key === "ArrowDown")) {
|
|
171
|
+
event.preventDefault()
|
|
172
|
+
this.open()
|
|
173
|
+
} else if (event.key === "Enter") {
|
|
174
|
+
this.validate()
|
|
175
|
+
} else if (event.key === "Escape") {
|
|
176
|
+
this.close()
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
calendarKeydown(event) {
|
|
181
|
+
const active = event.target.closest("[data-rdp-day]")
|
|
182
|
+
if (event.key === "Escape") {
|
|
183
|
+
event.preventDefault()
|
|
184
|
+
this.close({ restoreFocus: true })
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
if (!active) return
|
|
188
|
+
|
|
189
|
+
const moves = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }
|
|
190
|
+
let next
|
|
191
|
+
if (moves[event.key]) next = addDays(parseIso(active.dataset.iso), moves[event.key])
|
|
192
|
+
else if (event.key === "Home") next = addDays(parseIso(active.dataset.iso), -((active.dataset.weekday - this.weekStartIndex() + 7) % 7))
|
|
193
|
+
else if (event.key === "End") next = addDays(parseIso(active.dataset.iso), 6 - ((active.dataset.weekday - this.weekStartIndex() + 7) % 7))
|
|
194
|
+
else if (event.key === "PageUp" || event.key === "PageDown") {
|
|
195
|
+
const amount = event.key === "PageUp" ? -1 : 1
|
|
196
|
+
event.preventDefault()
|
|
197
|
+
if (event.shiftKey) this.navigateYears(amount)
|
|
198
|
+
else this.navigateMonths(amount)
|
|
199
|
+
this.focusPreferredDay()
|
|
200
|
+
return
|
|
201
|
+
} else return
|
|
202
|
+
|
|
203
|
+
event.preventDefault()
|
|
204
|
+
this.focusDate(next)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
handleOutside(event) {
|
|
208
|
+
if (this.opened && !this.inlineValue && !this.element.contains(event.target)) this.close()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
reposition() {
|
|
212
|
+
if (this.opened && !this.inlineValue) this.positionPopover()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
render() {
|
|
216
|
+
this.renderLocale()
|
|
217
|
+
this.renderToggle()
|
|
218
|
+
this.renderSelectors()
|
|
219
|
+
this.renderWeekdays()
|
|
220
|
+
this.renderDays()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
renderLocale() {
|
|
224
|
+
const copy = labels(this.localeValue)
|
|
225
|
+
this.element.lang = this.localeValue
|
|
226
|
+
for (const element of this.element.querySelectorAll("[data-rails-datepicker-label]")) {
|
|
227
|
+
element.setAttribute("aria-label", copy[element.dataset.railsDatepickerLabel])
|
|
228
|
+
}
|
|
229
|
+
for (const element of this.element.querySelectorAll("[data-rails-datepicker-text]")) {
|
|
230
|
+
element.textContent = copy[element.dataset.railsDatepickerText]
|
|
231
|
+
}
|
|
232
|
+
if (this.hasLocaleToggleTarget) {
|
|
233
|
+
this.localeToggleTarget.textContent = this.localeValue === "am" ? "English" : "አማርኛ"
|
|
234
|
+
this.localeToggleTarget.setAttribute("aria-label", copy.switchLanguage)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
renderToggle() {
|
|
239
|
+
const next = this.calendarValue === "ethiopian" ? "gregorian" : "ethiopian"
|
|
240
|
+
const copy = labels(this.localeValue)
|
|
241
|
+
this.calendarToggleTarget.textContent = copy[next]
|
|
242
|
+
this.calendarToggleTarget.setAttribute("aria-label", copy[`switchTo${next[0].toUpperCase()}${next.slice(1)}`])
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
renderSelectors() {
|
|
246
|
+
const current = calendarParts(this.viewDate, this.calendarValue)
|
|
247
|
+
const names = MONTHS[this.calendarValue][this.localeValue] || MONTHS[this.calendarValue].en
|
|
248
|
+
this.monthSelectTarget.innerHTML = names.map((name, index) => `<option value="${index + 1}"${index + 1 === current.month ? " selected" : ""}>${name}</option>`).join("")
|
|
249
|
+
const minYear = current.year - this.yearsBeforeValue
|
|
250
|
+
const maxYear = current.year + this.yearsAfterValue
|
|
251
|
+
let years = ""
|
|
252
|
+
for (let year = maxYear; year >= minYear; year -= 1) years += `<option value="${year}"${year === current.year ? " selected" : ""}>${year}</option>`
|
|
253
|
+
this.yearSelectTarget.innerHTML = years
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
renderWeekdays() {
|
|
257
|
+
const days = WEEKDAYS[this.localeValue] || WEEKDAYS.en
|
|
258
|
+
const ordered = [...days.slice(this.weekStartIndex()), ...days.slice(0, this.weekStartIndex())]
|
|
259
|
+
this.weekdaysTarget.innerHTML = ordered.map(day => `<span class="rdp__weekday" role="columnheader">${day}</span>`).join("")
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
renderDays() {
|
|
263
|
+
const parts = calendarParts(this.viewDate, this.calendarValue)
|
|
264
|
+
const firstDate = partsToGregorian({ year: parts.year, month: parts.month, day: 1 }, this.calendarValue)
|
|
265
|
+
const offset = (firstDate.getUTCDay() - this.weekStartIndex() + 7) % 7
|
|
266
|
+
const count = monthLength(parts.year, parts.month, this.calendarValue)
|
|
267
|
+
const todayIso = toIso(this.todayDate())
|
|
268
|
+
const selectedIso = this.selectedDate ? toIso(this.selectedDate) : ""
|
|
269
|
+
const visibleMonth = date => {
|
|
270
|
+
const value = calendarParts(date, this.calendarValue)
|
|
271
|
+
return value.year === parts.year && value.month === parts.month
|
|
272
|
+
}
|
|
273
|
+
let tabbableIso = ""
|
|
274
|
+
if (this.selectedDate && visibleMonth(this.selectedDate) && !this.restricted(this.selectedDate)) {
|
|
275
|
+
tabbableIso = selectedIso
|
|
276
|
+
} else {
|
|
277
|
+
const today = this.todayDate()
|
|
278
|
+
if (visibleMonth(today) && !this.restricted(today)) tabbableIso = todayIso
|
|
279
|
+
}
|
|
280
|
+
if (!tabbableIso) {
|
|
281
|
+
for (let day = 1; day <= count; day += 1) {
|
|
282
|
+
const date = partsToGregorian({ ...parts, day }, this.calendarValue)
|
|
283
|
+
if (!this.restricted(date)) {
|
|
284
|
+
tabbableIso = toIso(date)
|
|
285
|
+
break
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const cells = []
|
|
290
|
+
|
|
291
|
+
for (let index = 0; index < Math.ceil((offset + count) / 7) * 7; index += 1) {
|
|
292
|
+
const day = index - offset + 1
|
|
293
|
+
if (day < 1 || day > count) {
|
|
294
|
+
cells.push("<span class=\"rdp__day rdp__day--empty\" role=\"gridcell\"></span>")
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
const date = this.calendarValue === "ethiopian" ? ethiopianToGregorian(parts.year, parts.month, day) : partsToGregorian({ ...parts, day }, "gregorian")
|
|
298
|
+
const iso = toIso(date)
|
|
299
|
+
const disabled = this.restricted(date)
|
|
300
|
+
const classes = ["rdp__day"]
|
|
301
|
+
if (iso === todayIso) classes.push("rdp__day--today")
|
|
302
|
+
if (iso === selectedIso) classes.push("rdp__day--selected")
|
|
303
|
+
cells.push(`<button type="button" class="${classes.join(" ")}" role="gridcell" data-rdp-day data-iso="${iso}" data-weekday="${date.getUTCDay()}" tabindex="${iso === tabbableIso ? "0" : "-1"}" aria-selected="${iso === selectedIso}"${iso === todayIso ? " aria-current=\"date\"" : ""}${disabled ? " disabled aria-disabled=\"true\"" : ""} data-action="rails-datepicker#selectDate">${this.dayLabel(day)}</button>`)
|
|
304
|
+
}
|
|
305
|
+
this.gridTarget.innerHTML = cells.join("")
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
dayLabel(day) {
|
|
309
|
+
if (!this.ethiopicNumeralsValue) return day
|
|
310
|
+
return formatDate(partsToGregorian({ year: 2000, month: 1, day }, "gregorian"), "DD", "gregorian", true).replace(/^0/, "")
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
navigateMonths(amount) {
|
|
314
|
+
const parts = shiftMonth({ ...calendarParts(this.viewDate, this.calendarValue), day: 1 }, amount, this.calendarValue)
|
|
315
|
+
this.viewDate = partsToGregorian(parts, this.calendarValue)
|
|
316
|
+
this.render()
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
navigateYears(amount) {
|
|
320
|
+
const parts = calendarParts(this.viewDate, this.calendarValue)
|
|
321
|
+
parts.year += amount
|
|
322
|
+
parts.day = 1
|
|
323
|
+
this.viewDate = partsToGregorian(parts, this.calendarValue)
|
|
324
|
+
this.render()
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
commit(date) {
|
|
328
|
+
this.selectedDate = date
|
|
329
|
+
this.viewDate = date
|
|
330
|
+
const iso = toIso(date)
|
|
331
|
+
this.selectedValue = iso
|
|
332
|
+
this.canonicalTarget.value = iso
|
|
333
|
+
this.inputTarget.value = this.formatSelection(date)
|
|
334
|
+
this.setValidity("")
|
|
335
|
+
this.dispatchChanges()
|
|
336
|
+
this.render()
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
dispatchChanges() {
|
|
340
|
+
for (const target of [this.canonicalTarget, this.inputTarget]) {
|
|
341
|
+
target.dispatchEvent(new Event("input", { bubbles: true }))
|
|
342
|
+
target.dispatchEvent(new Event("change", { bubbles: true }))
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
clearWithoutEvent() {
|
|
347
|
+
this.selectedDate = null
|
|
348
|
+
this.selectedValue = ""
|
|
349
|
+
this.canonicalTarget.value = ""
|
|
350
|
+
this.setValidity("")
|
|
351
|
+
this.render()
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
restricted(date) {
|
|
355
|
+
const iso = toIso(date)
|
|
356
|
+
return this.inputTarget.disabled ||
|
|
357
|
+
(this.hasMinValue && this.minValue !== "" && iso < this.minValue) ||
|
|
358
|
+
(this.hasMaxValue && this.maxValue !== "" && iso > this.maxValue) ||
|
|
359
|
+
this.disabledSet.has(iso)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
setValidity(message) {
|
|
363
|
+
this.inputTarget.setCustomValidity(message)
|
|
364
|
+
this.inputTarget.setAttribute("aria-invalid", String(Boolean(message)))
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
positionPopover() {
|
|
368
|
+
const anchor = this.element.querySelector(".rdp__input-group").getBoundingClientRect()
|
|
369
|
+
const panel = this.popoverTarget
|
|
370
|
+
const margin = 8
|
|
371
|
+
const width = Math.min(288, window.innerWidth - (margin * 2))
|
|
372
|
+
panel.style.width = `${width}px`
|
|
373
|
+
const panelHeight = panel.offsetHeight
|
|
374
|
+
const below = window.innerHeight - anchor.bottom
|
|
375
|
+
const top = below >= panelHeight + margin || below >= anchor.top ? anchor.bottom + margin : anchor.top - panelHeight - margin
|
|
376
|
+
const left = Math.min(Math.max(margin, anchor.left), window.innerWidth - width - margin)
|
|
377
|
+
panel.style.top = `${Math.max(margin, top)}px`
|
|
378
|
+
panel.style.left = `${left}px`
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
focusPreferredDay() {
|
|
382
|
+
const preferred = this.selectedDate ? toIso(this.selectedDate) : toIso(this.todayDate())
|
|
383
|
+
const target = this.gridTarget.querySelector(`[data-iso="${preferred}"]:not(:disabled)`) || this.gridTarget.querySelector("[data-rdp-day]:not(:disabled)")
|
|
384
|
+
target?.focus()
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
focusDate(date) {
|
|
388
|
+
if (this.restricted(date)) return
|
|
389
|
+
const targetParts = calendarParts(date, this.calendarValue)
|
|
390
|
+
const currentParts = calendarParts(this.viewDate, this.calendarValue)
|
|
391
|
+
if (targetParts.year !== currentParts.year || targetParts.month !== currentParts.month) {
|
|
392
|
+
this.viewDate = date
|
|
393
|
+
this.render()
|
|
394
|
+
}
|
|
395
|
+
const target = this.gridTarget.querySelector(`[data-iso="${toIso(date)}"]:not(:disabled)`)
|
|
396
|
+
target?.focus()
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
weekStartIndex() { return this.weekStartValue === "monday" ? 1 : 0 }
|
|
400
|
+
|
|
401
|
+
safeParseIso(value) {
|
|
402
|
+
try { return value ? parseIso(value) : null } catch (_) { return null }
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
todayDate() {
|
|
406
|
+
const now = new Date()
|
|
407
|
+
const date = new Date(0)
|
|
408
|
+
date.setUTCHours(0, 0, 0, 0)
|
|
409
|
+
date.setUTCFullYear(now.getFullYear(), now.getMonth(), now.getDate())
|
|
410
|
+
return date
|
|
411
|
+
}
|
|
412
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
const MS_PER_DAY = 86_400_000
|
|
2
|
+
const ETHIOPIAN_ANCHOR_YEAR = 2015
|
|
3
|
+
const ETHIOPIAN_ANCHOR_DATE = utcDate(2022, 9, 11)
|
|
4
|
+
|
|
5
|
+
export const MONTHS = {
|
|
6
|
+
gregorian: {
|
|
7
|
+
en: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
|
|
8
|
+
am: ["ጃንዩወሪ", "ፌብሩወሪ", "ማርች", "ኤፕሪል", "ሜይ", "ጁን", "ጁላይ", "ኦገስት", "ሴፕቴምበር", "ኦክቶበር", "ኖቬምበር", "ዲሴምበር"]
|
|
9
|
+
},
|
|
10
|
+
ethiopian: {
|
|
11
|
+
en: ["Meskerem", "Tikimt", "Hidar", "Tahsas", "Tir", "Yekatit", "Megabit", "Miazia", "Ginbot", "Sene", "Hamle", "Nehase", "Pagume"],
|
|
12
|
+
am: ["መስከረም", "ጥቅምት", "ኅዳር", "ታኅሣሥ", "ጥር", "የካቲት", "መጋቢት", "ሚያዝያ", "ግንቦት", "ሰኔ", "ሐምሌ", "ነሐሴ", "ጳጉሜን"]
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const WEEKDAYS = {
|
|
17
|
+
en: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
|
|
18
|
+
am: ["እሑድ", "ሰኞ", "ማክሰኞ", "ረቡዕ", "ሐሙስ", "ዓርብ", "ቅዳሜ"]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const LABELS = {
|
|
22
|
+
en: {
|
|
23
|
+
open_calendar: "Open calendar", choose_date: "Choose date",
|
|
24
|
+
previous_year: "Previous year", previous_month: "Previous month", next_month: "Next month", next_year: "Next year",
|
|
25
|
+
month: "Month", year: "Year", today: "Today", clear: "Clear", switchLanguage: "Switch to Amharic",
|
|
26
|
+
ethiopian: "Ethiopian", gregorian: "Gregorian", invalid: "Enter a valid date",
|
|
27
|
+
unavailable: "This date is unavailable", switchToEthiopian: "Switch to Ethiopian calendar",
|
|
28
|
+
switchToGregorian: "Switch to Gregorian calendar"
|
|
29
|
+
},
|
|
30
|
+
am: {
|
|
31
|
+
open_calendar: "ቀን መቁጠሪያ ክፈት", choose_date: "ቀን ይምረጡ",
|
|
32
|
+
previous_year: "ያለፈው ዓመት", previous_month: "ያለፈው ወር", next_month: "ቀጣይ ወር", next_year: "ቀጣይ ዓመት",
|
|
33
|
+
month: "ወር", year: "ዓመት", today: "ዛሬ", clear: "አጽዳ", switchLanguage: "ወደ እንግሊዝኛ ቀይር",
|
|
34
|
+
ethiopian: "የኢትዮጵያ", gregorian: "ጎርጎርዮሳዊ", invalid: "ትክክለኛ ቀን ያስገቡ",
|
|
35
|
+
unavailable: "ይህ ቀን አይገኝም", switchToEthiopian: "ወደ ኢትዮጵያ የቀን መቁጠሪያ ቀይር",
|
|
36
|
+
switchToGregorian: "ወደ ጎርጎርዮሳዊ የቀን መቁጠሪያ ቀይር"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function labels(locale = "en") {
|
|
41
|
+
return LABELS[locale] || LABELS.en
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function gregorianLeapYear(year) {
|
|
45
|
+
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function ethiopianLeapYear(year) {
|
|
49
|
+
return year % 4 === 3
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function ethiopianMonthLength(year, month) {
|
|
53
|
+
if (!Number.isInteger(year) || year < 1 || !Number.isInteger(month) || month < 1 || month > 13) return 0
|
|
54
|
+
return month <= 12 ? 30 : (ethiopianLeapYear(year) ? 6 : 5)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function gregorianMonthLength(year, month) {
|
|
58
|
+
if (!Number.isInteger(year) || year < 1 || !Number.isInteger(month) || month < 1 || month > 12) return 0
|
|
59
|
+
if (month === 2) return gregorianLeapYear(year) ? 29 : 28
|
|
60
|
+
return [4, 6, 9, 11].includes(month) ? 30 : 31
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function validEthiopianDate(year, month, day) {
|
|
64
|
+
return Number.isInteger(day) && day >= 1 && day <= ethiopianMonthLength(year, month)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function validGregorianDate(year, month, day) {
|
|
68
|
+
return Number.isInteger(day) && day >= 1 && day <= gregorianMonthLength(year, month)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function ethiopianNewYear(year) {
|
|
72
|
+
if (!Number.isInteger(year) || year < 1) throw new RangeError("Ethiopian year must be positive")
|
|
73
|
+
return addDays(ETHIOPIAN_ANCHOR_DATE, daysBeforeEthiopianYear(year) - daysBeforeEthiopianYear(ETHIOPIAN_ANCHOR_YEAR))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function ethiopianToGregorian(year, month, day) {
|
|
77
|
+
if (!validEthiopianDate(year, month, day)) throw new RangeError("Invalid Ethiopian date")
|
|
78
|
+
return addDays(ethiopianNewYear(year), ((month - 1) * 30) + day - 1)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function gregorianToEthiopian(value) {
|
|
82
|
+
const date = typeof value === "string" ? parseIso(value) : normalizedDate(value)
|
|
83
|
+
if (date < ethiopianNewYear(1)) throw new RangeError("Date predates the Ethiopian calendar")
|
|
84
|
+
let year = Math.max(date.getUTCFullYear() - 7, 1)
|
|
85
|
+
while (date < ethiopianNewYear(year)) year -= 1
|
|
86
|
+
while (date >= ethiopianNewYear(year + 1)) year += 1
|
|
87
|
+
const offset = daysBetween(ethiopianNewYear(year), date)
|
|
88
|
+
return { year, month: Math.floor(offset / 30) + 1, day: (offset % 30) + 1 }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function parseIso(value) {
|
|
92
|
+
const match = /^(\d{4,})-(\d{2})-(\d{2})$/.exec(String(value || ""))
|
|
93
|
+
if (!match) throw new RangeError("Date must use YYYY-MM-DD")
|
|
94
|
+
const [year, month, day] = match.slice(1).map(Number)
|
|
95
|
+
if (!validGregorianDate(year, month, day)) throw new RangeError("Invalid Gregorian date")
|
|
96
|
+
return utcDate(year, month, day)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function toIso(value) {
|
|
100
|
+
const date = normalizedDate(value)
|
|
101
|
+
return `${String(date.getUTCFullYear()).padStart(4, "0")}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function calendarParts(date, calendar) {
|
|
105
|
+
if (calendar === "ethiopian") return gregorianToEthiopian(date)
|
|
106
|
+
const normalized = normalizedDate(date)
|
|
107
|
+
return { year: normalized.getUTCFullYear(), month: normalized.getUTCMonth() + 1, day: normalized.getUTCDate() }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function partsToGregorian(parts, calendar) {
|
|
111
|
+
const { year, month, day } = parts
|
|
112
|
+
if (calendar === "ethiopian") return ethiopianToGregorian(year, month, day)
|
|
113
|
+
if (!validGregorianDate(year, month, day)) throw new RangeError("Invalid Gregorian date")
|
|
114
|
+
return utcDate(year, month, day)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function monthLength(year, month, calendar) {
|
|
118
|
+
return calendar === "ethiopian" ? ethiopianMonthLength(year, month) : gregorianMonthLength(year, month)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function shiftMonth(parts, amount, calendar) {
|
|
122
|
+
const count = calendar === "ethiopian" ? 13 : 12
|
|
123
|
+
const index = (parts.year * count) + parts.month - 1 + amount
|
|
124
|
+
const year = Math.floor(index / count)
|
|
125
|
+
const month = ((index % count) + count) % count + 1
|
|
126
|
+
return { year, month, day: Math.min(parts.day || 1, monthLength(year, month, calendar)) }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function parseFormatted(value, format, calendar) {
|
|
130
|
+
const raw = String(value || "").trim()
|
|
131
|
+
const containsEthiopicNumerals = /[፩-፺፻፼]/u.test(raw)
|
|
132
|
+
const normalized = normalizeEthiopicNumerals(raw)
|
|
133
|
+
const tokens = []
|
|
134
|
+
const configuredFormat = String(format || "YYYY-MM-DD")
|
|
135
|
+
let pattern = ""
|
|
136
|
+
let lastIndex = 0
|
|
137
|
+
for (const match of configuredFormat.matchAll(/YYYY|MM|DD/g)) {
|
|
138
|
+
pattern += escapeRegex(configuredFormat.slice(lastIndex, match.index))
|
|
139
|
+
const token = match[0]
|
|
140
|
+
tokens.push(token)
|
|
141
|
+
pattern += token === "YYYY" ? "(\\d{4,})" : (containsEthiopicNumerals ? "(\\d{1,2})" : "(\\d{2})")
|
|
142
|
+
lastIndex = match.index + token.length
|
|
143
|
+
}
|
|
144
|
+
pattern += escapeRegex(configuredFormat.slice(lastIndex))
|
|
145
|
+
const parsed = new RegExp(`^${pattern}$`).exec(normalized)
|
|
146
|
+
if (!parsed || tokens.length !== 3 || new Set(tokens).size !== 3) throw new RangeError("Invalid date format")
|
|
147
|
+
const values = {}
|
|
148
|
+
tokens.forEach((token, index) => { values[token] = Number(parsed[index + 1]) })
|
|
149
|
+
return partsToGregorian({ year: values.YYYY, month: values.MM, day: values.DD }, calendar)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function formatDate(value, format, calendar, useEthiopicNumerals = false) {
|
|
153
|
+
const { year, month, day } = calendarParts(value, calendar)
|
|
154
|
+
const result = String(format || "YYYY-MM-DD")
|
|
155
|
+
.replace("YYYY", String(year).padStart(4, "0"))
|
|
156
|
+
.replace("MM", String(month).padStart(2, "0"))
|
|
157
|
+
.replace("DD", String(day).padStart(2, "0"))
|
|
158
|
+
return useEthiopicNumerals ? result.replace(/\d+/g, number => encodeEthiopicInteger(Number(number))) : result
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function encodeEthiopicInteger(number) {
|
|
162
|
+
if (number === 0) return "0"
|
|
163
|
+
if (!Number.isSafeInteger(number) || number < 0) throw new RangeError("Expected a positive integer")
|
|
164
|
+
if (number >= 10000) {
|
|
165
|
+
const high = Math.floor(number / 10000)
|
|
166
|
+
const low = number % 10000
|
|
167
|
+
return `${high === 1 ? "" : encodeEthiopicInteger(high)}፼${low ? encodeEthiopicInteger(low) : ""}`
|
|
168
|
+
}
|
|
169
|
+
const hundreds = Math.floor(number / 100)
|
|
170
|
+
const remainder = number % 100
|
|
171
|
+
const prefix = hundreds ? `${hundreds === 1 ? "" : encodeBelowHundred(hundreds)}፻` : ""
|
|
172
|
+
return `${prefix}${encodeBelowHundred(remainder)}`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function normalizeEthiopicNumerals(value) {
|
|
176
|
+
return value.replace(/[፩-፺፻፼]+/gu, token => String(decodeEthiopicInteger(token)))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function decodeEthiopicInteger(value) {
|
|
180
|
+
const text = String(value)
|
|
181
|
+
if (text.includes("፼")) {
|
|
182
|
+
const [left, ...rest] = text.split("፼")
|
|
183
|
+
return (left ? decodeEthiopicInteger(left) : 1) * 10000 + (rest.join("፼") ? decodeEthiopicInteger(rest.join("፼")) : 0)
|
|
184
|
+
}
|
|
185
|
+
const [left, right] = text.split("፻")
|
|
186
|
+
if (right !== undefined) return (left ? decodeBelowHundred(left) : 1) * 100 + decodeBelowHundred(right)
|
|
187
|
+
return decodeBelowHundred(left)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function addDays(value, days) {
|
|
191
|
+
const date = normalizedDate(value)
|
|
192
|
+
return new Date(date.getTime() + (days * MS_PER_DAY))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function daysBetween(from, to) {
|
|
196
|
+
return Math.round((normalizedDate(to).getTime() - normalizedDate(from).getTime()) / MS_PER_DAY)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function utcDate(year, month, day) {
|
|
200
|
+
const date = new Date(0)
|
|
201
|
+
date.setUTCHours(0, 0, 0, 0)
|
|
202
|
+
date.setUTCFullYear(year, month - 1, day)
|
|
203
|
+
return date
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function normalizedDate(value) {
|
|
207
|
+
if (!(value instanceof Date) || Number.isNaN(value.getTime())) throw new RangeError("Invalid date")
|
|
208
|
+
return utcDate(value.getUTCFullYear(), value.getUTCMonth() + 1, value.getUTCDate())
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function daysBeforeEthiopianYear(year) {
|
|
212
|
+
return (365 * (year - 1)) + Math.floor(year / 4)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function escapeRegex(value) {
|
|
216
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const ONES = ["", "፩", "፪", "፫", "፬", "፭", "፮", "፯", "፰", "፱"]
|
|
220
|
+
const TENS = ["", "፲", "፳", "፴", "፵", "፶", "፷", "፸", "፹", "፺"]
|
|
221
|
+
|
|
222
|
+
function encodeBelowHundred(number) {
|
|
223
|
+
return `${TENS[Math.floor(number / 10)]}${ONES[number % 10]}`
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function decodeBelowHundred(text = "") {
|
|
227
|
+
let result = 0
|
|
228
|
+
for (const character of text) {
|
|
229
|
+
const one = ONES.indexOf(character)
|
|
230
|
+
const ten = TENS.indexOf(character)
|
|
231
|
+
if (one > 0) result += one
|
|
232
|
+
else if (ten > 0) result += ten * 10
|
|
233
|
+
else throw new RangeError("Invalid Ethiopic numeral")
|
|
234
|
+
}
|
|
235
|
+
return result
|
|
236
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./calendar.js"
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
en:
|
|
2
|
+
rails_datepicker:
|
|
3
|
+
open_calendar: "Open calendar"
|
|
4
|
+
choose_date: "Choose date"
|
|
5
|
+
previous_year: "Previous year"
|
|
6
|
+
previous_month: "Previous month"
|
|
7
|
+
next_month: "Next month"
|
|
8
|
+
next_year: "Next year"
|
|
9
|
+
month: "Month"
|
|
10
|
+
year: "Year"
|
|
11
|
+
today: "Today"
|
|
12
|
+
clear: "Clear"
|