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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +29 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +166 -0
  5. data/app/assets/stylesheets/rails_datepicker.css +98 -0
  6. data/app/javascript/controllers/rails_datepicker_controller.js +412 -0
  7. data/app/javascript/rails-datepicker/calendar.js +236 -0
  8. data/app/javascript/rails-datepicker/index.js +1 -0
  9. data/config/importmap.rb +2 -0
  10. data/config/locales/rails_datepicker.am.yml +12 -0
  11. data/config/locales/rails_datepicker.en.yml +12 -0
  12. data/lib/generators/rails_datepicker/install_generator.rb +87 -0
  13. data/lib/generators/rails_datepicker/templates/initializer.rb.tt +9 -0
  14. data/lib/rails-datepicker.rb +4 -0
  15. data/lib/rails_datepicker/calendar.rb +150 -0
  16. data/lib/rails_datepicker/configuration.rb +55 -0
  17. data/lib/rails_datepicker/engine.rb +31 -0
  18. data/lib/rails_datepicker/form_helper.rb +267 -0
  19. data/lib/rails_datepicker/simple_form_input.rb +17 -0
  20. data/lib/rails_datepicker/version.rb +5 -0
  21. data/lib/rails_datepicker.rb +25 -0
  22. data/package.json +22 -0
  23. data/test/application_system_test_case.rb +11 -0
  24. data/test/dummy/app/controllers/application_controller.rb +4 -0
  25. data/test/dummy/app/controllers/students_controller.rb +27 -0
  26. data/test/dummy/app/javascript/application.js +6 -0
  27. data/test/dummy/app/models/application_record.rb +5 -0
  28. data/test/dummy/app/models/student.rb +5 -0
  29. data/test/dummy/app/views/layouts/application.html.erb +10 -0
  30. data/test/dummy/app/views/students/frame.html.erb +8 -0
  31. data/test/dummy/app/views/students/new.html.erb +21 -0
  32. data/test/dummy/config/application.rb +16 -0
  33. data/test/dummy/config/boot.rb +2 -0
  34. data/test/dummy/config/cable.yml +2 -0
  35. data/test/dummy/config/database.yml +3 -0
  36. data/test/dummy/config/environment.rb +4 -0
  37. data/test/dummy/config/environments/test.rb +11 -0
  38. data/test/dummy/config/importmap.rb +5 -0
  39. data/test/dummy/config/routes.rb +7 -0
  40. data/test/dummy/db/schema.rb +7 -0
  41. data/test/fixtures/tailwind.css +2 -0
  42. data/test/generators/install_generator_test.rb +53 -0
  43. data/test/helpers/form_helper_test.rb +77 -0
  44. data/test/integration/form_submission_test.rb +22 -0
  45. data/test/javascript/calendar_test.js +56 -0
  46. data/test/rails_datepicker/calendar_test.rb +57 -0
  47. data/test/rails_datepicker/configuration_test.rb +39 -0
  48. data/test/simple_form_input_test.rb +20 -0
  49. data/test/system/datepicker_test.rb +93 -0
  50. data/test/test_helper.rb +9 -0
  51. 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"
@@ -0,0 +1,2 @@
1
+ pin "rails-datepicker/calendar", to: "rails-datepicker/calendar.js"
2
+ pin "rails-datepicker/controller", to: "controllers/rails_datepicker_controller.js"
@@ -0,0 +1,12 @@
1
+ am:
2
+ rails_datepicker:
3
+ open_calendar: "ቀን መቁጠሪያ ክፈት"
4
+ choose_date: "ቀን ይምረጡ"
5
+ previous_year: "ያለፈው ዓመት"
6
+ previous_month: "ያለፈው ወር"
7
+ next_month: "ቀጣይ ወር"
8
+ next_year: "ቀጣይ ዓመት"
9
+ month: "ወር"
10
+ year: "ዓመት"
11
+ today: "ዛሬ"
12
+ clear: "አጽዳ"
@@ -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"