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,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module RailsDatepicker
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ class_option :skip_tailwind, type: :boolean, default: false, desc: "Skip Tailwind CSS integration"
11
+
12
+ def copy_initializer
13
+ template "initializer.rb.tt", "config/initializers/rails_datepicker.rb"
14
+ end
15
+
16
+ def install_javascript
17
+ copy_file gem_file("app/javascript/controllers/rails_datepicker_controller.js"), "app/javascript/controllers/rails_datepicker_controller.js"
18
+ copy_file gem_file("app/javascript/rails-datepicker/calendar.js"), "app/javascript/rails-datepicker/calendar.js"
19
+ unless importmap?
20
+ gsub_file "app/javascript/controllers/rails_datepicker_controller.js",
21
+ 'from "rails-datepicker/calendar"',
22
+ 'from "../rails-datepicker/calendar.js"'
23
+ end
24
+ end
25
+
26
+ def configure_importmap
27
+ return unless importmap?
28
+ return if File.read(destination_path("config/importmap.rb")).include?('pin "rails-datepicker/calendar"')
29
+
30
+ append_to_file "config/importmap.rb", <<~RUBY
31
+
32
+ # rails-datepicker (the controller itself is discovered by stimulus-rails)
33
+ pin "rails-datepicker/calendar", to: "rails-datepicker/calendar.js"
34
+ RUBY
35
+ end
36
+
37
+ def install_tailwind
38
+ return if options[:skip_tailwind]
39
+
40
+ stylesheet = tailwind_stylesheet
41
+ component_path = if stylesheet
42
+ File.join(File.dirname(stylesheet), "rails_datepicker.css")
43
+ else
44
+ "app/assets/tailwind/rails_datepicker.css"
45
+ end
46
+ copy_file gem_file("app/assets/stylesheets/rails_datepicker.css"), component_path
47
+ if stylesheet
48
+ import = '@import "./rails_datepicker.css";'
49
+ append_to_file(stylesheet, "\n#{import}\n") unless File.read(destination_path(stylesheet)).include?(import)
50
+ else
51
+ say "Tailwind entrypoint not found. Import app/assets/tailwind/rails_datepicker.css from your Tailwind CSS 4 stylesheet.", :yellow
52
+ end
53
+ end
54
+
55
+ def finish
56
+ say "Rails Datepicker installed. Restart the asset watcher before testing the field.", :green
57
+ end
58
+
59
+ private
60
+
61
+ def gem_file(relative)
62
+ File.expand_path("../../../#{relative}", __dir__)
63
+ end
64
+
65
+ def destination_path(relative)
66
+ File.join(destination_root, relative)
67
+ end
68
+
69
+ def tailwind_stylesheet
70
+ known = %w[
71
+ app/assets/tailwind/application.css
72
+ app/assets/stylesheets/application.tailwind.css
73
+ ].find { |path| File.exist?(destination_path(path)) }
74
+ return known if known
75
+
76
+ generic = "app/assets/stylesheets/application.css"
77
+ return unless File.exist?(destination_path(generic))
78
+
79
+ generic if File.read(destination_path(generic)).match?(/@import\s+["']tailwindcss["']/)
80
+ end
81
+
82
+ def importmap?
83
+ File.exist?(destination_path("config/importmap.rb"))
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ RailsDatepicker.configure do |config|
4
+ config.default_calendar = :ethiopian
5
+ config.default_locale = :am
6
+ config.date_format = "YYYY-MM-DD"
7
+ config.theme = :auto
8
+ config.week_start = :sunday
9
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler auto-requires the gem name. Keep the public Ruby require idiomatic too.
4
+ require_relative "rails_datepicker"
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module RailsDatepicker
6
+ module Calendar
7
+ class InvalidDate < ArgumentError; end
8
+
9
+ ETHIOPIAN_ANCHOR_YEAR = 2015
10
+ ETHIOPIAN_ANCHOR_DATE = Date.new(2022, 9, 11, Date::GREGORIAN)
11
+
12
+ module_function
13
+
14
+ def ethiopian_leap_year?(year)
15
+ Integer(year) % 4 == 3
16
+ end
17
+
18
+ def gregorian_leap_year?(year)
19
+ Date.gregorian_leap?(Integer(year))
20
+ end
21
+
22
+ def ethiopian_month_length(year, month)
23
+ year = Integer(year)
24
+ month = Integer(month)
25
+ raise InvalidDate, "Ethiopian month must be between 1 and 13" unless (1..13).cover?(month)
26
+
27
+ return 30 if month <= 12
28
+
29
+ ethiopian_leap_year?(year) ? 6 : 5
30
+ end
31
+
32
+ def valid_ethiopian_date?(year, month, day)
33
+ year = Integer(year)
34
+ month = Integer(month)
35
+ day = Integer(day)
36
+ year.positive? && day.between?(1, ethiopian_month_length(year, month))
37
+ rescue ArgumentError, TypeError
38
+ false
39
+ end
40
+
41
+ def ethiopian_to_gregorian(year, month, day)
42
+ year, month, day = [year, month, day].map { |part| Integer(part) }
43
+ raise InvalidDate, "invalid Ethiopian date" unless valid_ethiopian_date?(year, month, day)
44
+
45
+ new_year = ethiopian_new_year(year)
46
+ new_year + ((month - 1) * 30) + day - 1
47
+ end
48
+
49
+ def gregorian_to_ethiopian(value, month = nil, day = nil)
50
+ date = coerce_gregorian(value, month, day)
51
+ raise InvalidDate, "date predates the Ethiopian calendar" if date < ethiopian_new_year(1)
52
+
53
+ ethiopian_year = [date.year - 7, 1].max
54
+ ethiopian_year -= 1 while date < ethiopian_new_year(ethiopian_year)
55
+ ethiopian_year += 1 while date >= ethiopian_new_year(ethiopian_year + 1)
56
+ offset = (date - ethiopian_new_year(ethiopian_year)).to_i
57
+
58
+ [ethiopian_year, (offset / 30) + 1, (offset % 30) + 1]
59
+ end
60
+
61
+ def ethiopian_new_year(ethiopian_year)
62
+ ethiopian_year = Integer(ethiopian_year)
63
+ raise InvalidDate, "Ethiopian year must be positive" unless ethiopian_year.positive?
64
+
65
+ ETHIOPIAN_ANCHOR_DATE + days_before_ethiopian_year(ethiopian_year) -
66
+ days_before_ethiopian_year(ETHIOPIAN_ANCHOR_YEAR)
67
+ end
68
+
69
+ def days_before_ethiopian_year(year)
70
+ (365 * (year - 1)) + (year / 4)
71
+ end
72
+ private_class_method :days_before_ethiopian_year
73
+
74
+ def parse_iso(value)
75
+ match = /\A(\d{4,})-(\d{2})-(\d{2})\z/.match(value.to_s)
76
+ raise InvalidDate, "date must use YYYY-MM-DD" unless match
77
+
78
+ Date.new(*match.captures.map(&:to_i), Date::GREGORIAN)
79
+ rescue Date::Error
80
+ raise InvalidDate, "invalid Gregorian date"
81
+ end
82
+
83
+ def format_ethiopian(date, format = "YYYY-MM-DD", numerals: false)
84
+ year, month, day = gregorian_to_ethiopian(date)
85
+ formatted = apply_format(format, year, month, day)
86
+ numerals ? to_ethiopic_numerals(formatted) : formatted
87
+ end
88
+
89
+ def format_gregorian(date, format = "YYYY-MM-DD", numerals: false)
90
+ date = coerce_gregorian(date)
91
+ formatted = apply_format(format, date.year, date.month, date.day)
92
+ numerals ? to_ethiopic_numerals(formatted) : formatted
93
+ end
94
+
95
+ def to_ethiopic_numerals(value)
96
+ value.to_s.gsub(/\d+/) { |digits| encode_ethiopic_integer(digits.to_i) }
97
+ end
98
+
99
+ def apply_format(format, year, month, day)
100
+ format.to_s.gsub("YYYY", year.to_s.rjust(4, "0"))
101
+ .gsub("MM", month.to_s.rjust(2, "0"))
102
+ .gsub("DD", day.to_s.rjust(2, "0"))
103
+ end
104
+ private_class_method :apply_format
105
+
106
+ def coerce_gregorian(value, month = nil, day = nil)
107
+ return Date.new(Integer(value), Integer(month), Integer(day), Date::GREGORIAN) unless month.nil? || day.nil?
108
+ return value if value.is_a?(Date)
109
+ return value.to_date if value.respond_to?(:to_date)
110
+
111
+ parse_iso(value)
112
+ rescue Date::Error, ArgumentError, TypeError
113
+ raise InvalidDate, "invalid Gregorian date"
114
+ end
115
+ private_class_method :coerce_gregorian
116
+
117
+ ETHIOPIC_ONES = ["", "፩", "፪", "፫", "፬", "፭", "፮", "፯", "፰", "፱"].freeze
118
+ ETHIOPIC_TENS = ["", "፲", "፳", "፴", "፵", "፶", "፷", "፸", "፹", "፺"].freeze
119
+
120
+ def encode_ethiopic_integer(number)
121
+ return "0" if number.zero?
122
+
123
+ groups = []
124
+ while number.positive?
125
+ groups.unshift(number % 100)
126
+ number /= 100
127
+ end
128
+
129
+ groups.each_with_index.filter_map do |group, index|
130
+ power = groups.length - index - 1
131
+ next if group.zero?
132
+
133
+ coefficient = if group == 1 && power.positive?
134
+ ""
135
+ else
136
+ "#{ETHIOPIC_TENS[group / 10]}#{ETHIOPIC_ONES[group % 10]}"
137
+ end
138
+ marker = if power.zero?
139
+ ""
140
+ elsif power.odd?
141
+ "፼" * (power / 2) + "፻"
142
+ else
143
+ "፼" * (power / 2)
144
+ end
145
+ "#{coefficient}#{marker}"
146
+ end.join
147
+ end
148
+ private_class_method :encode_ethiopic_integer
149
+ end
150
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDatepicker
4
+ class Configuration
5
+ CALENDARS = %i[ethiopian gregorian].freeze
6
+ LOCALES = %i[am en].freeze
7
+ THEMES = %i[auto light dark].freeze
8
+ WEEK_STARTS = %i[sunday monday].freeze
9
+
10
+ attr_reader :default_calendar, :default_locale, :date_format, :theme, :week_start
11
+
12
+ def initialize
13
+ @default_calendar = :ethiopian
14
+ @default_locale = :am
15
+ @date_format = "YYYY-MM-DD"
16
+ @theme = :auto
17
+ @week_start = :sunday
18
+ end
19
+
20
+ def default_calendar=(value)
21
+ @default_calendar = validate!(:calendar, value, CALENDARS)
22
+ end
23
+
24
+ def default_locale=(value)
25
+ @default_locale = validate!(:locale, value, LOCALES)
26
+ end
27
+
28
+ def theme=(value)
29
+ @theme = validate!(:theme, value, THEMES)
30
+ end
31
+
32
+ def week_start=(value)
33
+ @week_start = validate!(:week_start, value, WEEK_STARTS)
34
+ end
35
+
36
+ def date_format=(value)
37
+ format = value.to_s
38
+ tokens = format.scan(/YYYY|MM|DD/)
39
+ unless tokens.length == 3 && tokens.uniq.length == 3
40
+ raise ArgumentError, "date_format must contain YYYY, MM, and DD exactly once"
41
+ end
42
+
43
+ @date_format = format
44
+ end
45
+
46
+ private
47
+
48
+ def validate!(name, value, allowed)
49
+ normalized = value.to_sym
50
+ return normalized if allowed.include?(normalized)
51
+
52
+ raise ArgumentError, "invalid #{name}: #{value.inspect}; expected one of #{allowed.join(', ')}"
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+ require_relative "form_helper"
5
+
6
+ module RailsDatepicker
7
+ class Engine < ::Rails::Engine
8
+ isolate_namespace RailsDatepicker
9
+
10
+ initializer "rails_datepicker.helpers" do
11
+ ActiveSupport.on_load(:action_view) do
12
+ include RailsDatepicker::FormHelper
13
+ end
14
+ ActionView::Helpers::FormBuilder.include RailsDatepicker::FormBuilder
15
+ end
16
+
17
+ initializer "rails_datepicker.importmap", before: "importmap" do |app|
18
+ if app.config.respond_to?(:importmap)
19
+ app.config.importmap.paths << root.join("config/importmap.rb")
20
+ app.config.assets.paths << root.join("app/javascript") if app.config.respond_to?(:assets)
21
+ end
22
+ end
23
+
24
+ config.after_initialize do
25
+ if defined?(SimpleForm) || Gem.loaded_specs.key?("simple_form")
26
+ require "simple_form" unless defined?(SimpleForm)
27
+ require "rails_datepicker/simple_form_input"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RailsDatepicker
6
+ module FormHelper
7
+ FIELD_OPTIONS = %i[
8
+ calendar display_calendar locale date_format format theme week_start ethiopic_numerals
9
+ inline min max disabled_dates readonly years_before years_after
10
+ ].freeze
11
+
12
+ def rails_datepicker_field(object_name, method, options = {})
13
+ object = options.delete(:object) || rails_datepicker_object_for(object_name)
14
+ builder = ActionView::Helpers::FormBuilder.new(object_name, object, self, {})
15
+ builder.datepicker_field(method, options)
16
+ end
17
+
18
+ private
19
+
20
+ def rails_datepicker_object_for(object_name)
21
+ root = object_name.to_s.sub(/\[.*\]\z/, "")
22
+ instance_variable_get("@#{root}")
23
+ end
24
+ end
25
+
26
+ module FormBuilder
27
+ def datepicker_field(method, options = {})
28
+ config = RailsDatepicker.configuration
29
+ picker = extract_picker_options(options, config)
30
+ html_options = options.dup
31
+ value = html_options.key?(:value) ? html_options.delete(:value) : object&.public_send(method)
32
+ iso_value = canonical_iso(value)
33
+ visible_value = display_value(iso_value, picker)
34
+ base_id = html_options.delete(:id) || field_id(method)
35
+ canonical_id = "#{base_id}_gregorian"
36
+ disabled = html_options[:disabled]
37
+ required = html_options[:required]
38
+ readonly = picker[:readonly] || html_options.delete(:readonly)
39
+ aria_options = html_options.delete(:aria)
40
+ errors = object.respond_to?(:errors) ? object.errors[method] : []
41
+ invalid = errors.any?
42
+
43
+ data = stimulus_data(picker, iso_value)
44
+ wrapper_classes = ["rdp", "rdp--#{picker[:theme]}", ("rdp--inline" if picker[:inline])].compact.join(" ")
45
+
46
+ @template.content_tag(:div, class: wrapper_classes, lang: picker[:locale], data: data) do
47
+ canonical = hidden_field(method,
48
+ id: canonical_id,
49
+ value: iso_value,
50
+ disabled: disabled,
51
+ data: { rails_datepicker_target: "canonical" })
52
+
53
+ visible_options = html_options.merge(
54
+ id: base_id,
55
+ name: nil,
56
+ value: visible_value,
57
+ readonly: readonly,
58
+ required: required,
59
+ disabled: disabled,
60
+ autocomplete: "off",
61
+ inputmode: "numeric",
62
+ role: "combobox",
63
+ aria: input_aria(base_id, picker, invalid, aria_options),
64
+ class: ["rdp__input", html_options[:class]].compact.join(" "),
65
+ data: merge_data(html_options[:data],
66
+ rails_datepicker_target: "input",
67
+ action: "input->rails-datepicker#input blur->rails-datepicker#validate keydown->rails-datepicker#inputKeydown")
68
+ )
69
+ visible_options.delete(:class) if visible_options[:class].empty?
70
+ input = text_field(method, visible_options)
71
+ trigger = picker[:inline] ? nil : calendar_trigger(base_id, disabled, picker[:locale])
72
+ input_group = @template.content_tag(:div, @template.safe_join([input, trigger].compact), class: "rdp__input-group")
73
+ panel = calendar_panel(base_id, picker, disabled)
74
+ error = if invalid
75
+ @template.content_tag(:p, errors.to_sentence, id: "#{base_id}_error", class: "rdp__error", role: "alert")
76
+ end
77
+
78
+ @template.safe_join([canonical, input_group, panel, error].compact)
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def extract_picker_options(options, config)
85
+ date_format = options.delete(:date_format) || options.delete(:format) || config.date_format
86
+ validate_date_format!(date_format)
87
+ min = constraint_iso(options.delete(:min), :min)
88
+ max = constraint_iso(options.delete(:max), :max)
89
+ raise ArgumentError, "min must not be after max" if !min.empty? && !max.empty? && min > max
90
+ years_before = Integer(options.delete(:years_before) || 100)
91
+ years_after = Integer(options.delete(:years_after) || 20)
92
+ raise ArgumentError, "year ranges must be non-negative" if years_before.negative? || years_after.negative?
93
+
94
+ calendar = options.delete(:calendar) || config.default_calendar
95
+ {
96
+ calendar: picker_enum!(:calendar, calendar, Configuration::CALENDARS),
97
+ display_calendar: (picker_enum!(:display_calendar, options.delete(:display_calendar), Configuration::CALENDARS) if options[:display_calendar]),
98
+ locale: picker_enum!(:locale, options.delete(:locale) || config.default_locale, Configuration::LOCALES),
99
+ date_format: date_format,
100
+ theme: picker_enum!(:theme, options.delete(:theme) || config.theme, Configuration::THEMES),
101
+ week_start: picker_enum!(:week_start, options.delete(:week_start) || config.week_start, Configuration::WEEK_STARTS),
102
+ ethiopic_numerals: !!options.delete(:ethiopic_numerals),
103
+ inline: !!options.delete(:inline),
104
+ min: min,
105
+ max: max,
106
+ disabled_dates: Array(options.delete(:disabled_dates)).map { |date| constraint_iso(date, :disabled_dates) },
107
+ readonly: !!options.delete(:readonly),
108
+ years_before: years_before,
109
+ years_after: years_after
110
+ }
111
+ end
112
+
113
+ def validate_date_format!(format)
114
+ tokens = format.to_s.scan(/YYYY|MM|DD/)
115
+ return if tokens.length == 3 && tokens.uniq.length == 3
116
+
117
+ raise ArgumentError, "date_format must contain YYYY, MM, and DD exactly once"
118
+ end
119
+
120
+ def constraint_iso(value, name)
121
+ return "" if value.nil? || value == ""
122
+
123
+ result = canonical_iso(value)
124
+ RailsDatepicker::Calendar.parse_iso(result).strftime("%Y-%m-%d")
125
+ rescue RailsDatepicker::Calendar::InvalidDate
126
+ raise ArgumentError, "#{name} must be a Gregorian date or YYYY-MM-DD value"
127
+ end
128
+
129
+ def picker_enum!(name, value, allowed)
130
+ normalized = value.to_sym
131
+ return normalized if allowed.include?(normalized)
132
+
133
+ raise ArgumentError, "invalid #{name}: #{value.inspect}; expected one of #{allowed.join(', ')}"
134
+ end
135
+
136
+ def stimulus_data(picker, iso_value)
137
+ {
138
+ controller: "rails-datepicker",
139
+ rails_datepicker_calendar_value: picker[:calendar],
140
+ rails_datepicker_display_calendar_value: picker[:display_calendar],
141
+ rails_datepicker_locale_value: picker[:locale],
142
+ rails_datepicker_format_value: picker[:date_format],
143
+ rails_datepicker_theme_value: picker[:theme],
144
+ rails_datepicker_week_start_value: picker[:week_start],
145
+ rails_datepicker_ethiopic_numerals_value: picker[:ethiopic_numerals],
146
+ rails_datepicker_inline_value: picker[:inline],
147
+ rails_datepicker_min_value: picker[:min].to_s,
148
+ rails_datepicker_max_value: picker[:max].to_s,
149
+ rails_datepicker_disabled_dates_value: picker[:disabled_dates].to_json,
150
+ rails_datepicker_years_before_value: picker[:years_before],
151
+ rails_datepicker_years_after_value: picker[:years_after],
152
+ rails_datepicker_selected_value: iso_value.to_s
153
+ }
154
+ end
155
+
156
+ def calendar_trigger(base_id, disabled, locale)
157
+ @template.button_tag(
158
+ type: "button",
159
+ class: "rdp__trigger",
160
+ disabled: disabled,
161
+ aria: { label: translate("open_calendar", locale), controls: "#{base_id}_calendar" },
162
+ data: { rails_datepicker_label: "open_calendar", rails_datepicker_target: "trigger", action: "rails-datepicker#toggle" }
163
+ ) do
164
+ @template.content_tag(:svg, class: "rdp__icon", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", aria: { hidden: "true" }) do
165
+ @template.safe_join([
166
+ @template.tag.path(d: "M8 2v4M16 2v4M3 10h18", stroke_width: 2, stroke_linecap: "round"),
167
+ @template.tag.rect(x: 3, y: 5, width: 18, height: 16, rx: 2, stroke_width: 2)
168
+ ])
169
+ end
170
+ end
171
+ end
172
+
173
+ def calendar_panel(base_id, picker, disabled)
174
+ @template.content_tag(:div,
175
+ id: "#{base_id}_calendar",
176
+ class: "rdp__popover",
177
+ hidden: !picker[:inline],
178
+ role: "dialog",
179
+ aria: { modal: "false", label: translate("choose_date", picker[:locale]) },
180
+ data: { rails_datepicker_label: "choose_date", rails_datepicker_target: "popover", action: "keydown->rails-datepicker#calendarKeydown" }) do
181
+ @template.safe_join([
182
+ calendar_header(picker[:locale], disabled),
183
+ @template.content_tag(:div, "", class: "rdp__weekdays", role: "row", data: { rails_datepicker_target: "weekdays" }),
184
+ @template.content_tag(:div, "", class: "rdp__grid", role: "grid", data: { rails_datepicker_target: "grid" }),
185
+ calendar_footer(picker[:locale], disabled)
186
+ ])
187
+ end
188
+ end
189
+
190
+ def calendar_header(locale, disabled)
191
+ @template.content_tag(:div, class: "rdp__header") do
192
+ @template.safe_join([
193
+ nav_button("previous_month", "previousMonth", "‹", locale, disabled),
194
+ @template.content_tag(:div, class: "rdp__selectors") do
195
+ @template.safe_join([
196
+ @template.select_tag(nil, "", class: "rdp__select", disabled: disabled, aria: { label: translate("month", locale) }, data: { rails_datepicker_label: "month", rails_datepicker_target: "monthSelect", action: "change->rails-datepicker#selectMonth" }),
197
+ @template.select_tag(nil, "", class: "rdp__select", disabled: disabled, aria: { label: translate("year", locale) }, data: { rails_datepicker_label: "year", rails_datepicker_target: "yearSelect", action: "change->rails-datepicker#selectYear" })
198
+ ])
199
+ end,
200
+ nav_button("next_month", "nextMonth", "›", locale, disabled)
201
+ ])
202
+ end
203
+ end
204
+
205
+ def calendar_footer(locale, disabled)
206
+ @template.content_tag(:div, class: "rdp__footer") do
207
+ @template.safe_join([
208
+ @template.button_tag(translate("today", locale), type: "button", class: "rdp__footer-button", disabled: disabled, data: { rails_datepicker_text: "today", action: "rails-datepicker#today" }),
209
+ @template.button_tag(translate("clear", locale), type: "button", class: "rdp__footer-button", disabled: disabled, data: { rails_datepicker_text: "clear", action: "rails-datepicker#clear" }),
210
+ @template.button_tag("", type: "button", class: "rdp__locale-toggle", disabled: disabled, data: { rails_datepicker_target: "localeToggle", action: "rails-datepicker#switchLocale" }),
211
+ @template.button_tag("", type: "button", class: "rdp__calendar-toggle", disabled: disabled, data: { rails_datepicker_target: "calendarToggle", action: "rails-datepicker#switchCalendar" })
212
+ ])
213
+ end
214
+ end
215
+
216
+ def nav_button(label, action, glyph, locale, disabled)
217
+ @template.button_tag(glyph, type: "button", class: "rdp__nav", disabled: disabled, aria: { label: translate(label, locale) }, data: { rails_datepicker_label: label, action: "rails-datepicker##{action}" })
218
+ end
219
+
220
+ def canonical_iso(value)
221
+ return "" if value.nil? || value == ""
222
+
223
+ date = value.respond_to?(:to_date) ? value.to_date : RailsDatepicker::Calendar.parse_iso(value)
224
+ date.strftime("%Y-%m-%d")
225
+ rescue RailsDatepicker::Calendar::InvalidDate, Date::Error
226
+ value.to_s
227
+ end
228
+
229
+ def display_value(iso_value, picker)
230
+ return "" if iso_value.empty?
231
+
232
+ date = RailsDatepicker::Calendar.parse_iso(iso_value)
233
+ if (picker[:display_calendar] || picker[:calendar]) == :ethiopian
234
+ RailsDatepicker::Calendar.format_ethiopian(date, picker[:date_format], numerals: picker[:ethiopic_numerals])
235
+ else
236
+ RailsDatepicker::Calendar.format_gregorian(date, picker[:date_format], numerals: picker[:ethiopic_numerals])
237
+ end
238
+ rescue RailsDatepicker::Calendar::InvalidDate
239
+ iso_value
240
+ end
241
+
242
+ def input_aria(base_id, picker, invalid, existing)
243
+ supplied = (existing || {}).to_h.transform_keys(&:to_sym)
244
+ describedby = [supplied.delete(:describedby), ("#{base_id}_error" if invalid)].compact.join(" ")
245
+ aria = {
246
+ autocomplete: "none",
247
+ controls: "#{base_id}_calendar",
248
+ expanded: picker[:inline] ? "true" : "false",
249
+ haspopup: "dialog",
250
+ invalid: invalid.to_s
251
+ }.merge(supplied)
252
+ aria[:invalid] = "true" if invalid
253
+ aria[:describedby] = describedby unless describedby.empty?
254
+ aria
255
+ end
256
+
257
+ def merge_data(existing, additions)
258
+ (existing || {}).to_h.transform_keys(&:to_sym).merge(additions) do |key, old_value, new_value|
259
+ key.to_sym == :action ? "#{old_value} #{new_value}" : new_value
260
+ end
261
+ end
262
+
263
+ def translate(key, locale)
264
+ I18n.t("rails_datepicker.#{key}", locale: locale, default: key.to_s.tr("_", " ").capitalize)
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ return unless defined?(SimpleForm::Inputs::Base)
4
+
5
+ class RailsDatepickerInput < SimpleForm::Inputs::Base
6
+ PICKER_KEYS = RailsDatepicker::FormHelper::FIELD_OPTIONS
7
+
8
+ def input(wrapper_options = nil)
9
+ merged = merge_wrapper_options(input_html_options, wrapper_options)
10
+ picker_options = options.slice(*PICKER_KEYS)
11
+ @builder.datepicker_field(attribute_name, picker_options.merge(merged))
12
+ end
13
+
14
+ def input_type
15
+ :string
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDatepicker
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rails_datepicker/version"
4
+ require_relative "rails_datepicker/configuration"
5
+ require_relative "rails_datepicker/calendar"
6
+
7
+ module RailsDatepicker
8
+ class << self
9
+ attr_writer :configuration
10
+
11
+ def configuration
12
+ @configuration ||= Configuration.new
13
+ end
14
+
15
+ def configure
16
+ yield(configuration)
17
+ end
18
+
19
+ def reset_configuration!
20
+ @configuration = Configuration.new
21
+ end
22
+ end
23
+ end
24
+
25
+ require_relative "rails_datepicker/engine" if defined?(Rails::Engine)
data/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "rails-datepicker",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ "./calendar": "./app/javascript/rails-datepicker/calendar.js",
8
+ "./controller": "./app/javascript/controllers/rails_datepicker_controller.js"
9
+ },
10
+ "scripts": {
11
+ "test": "node --test test/javascript/**/*_test.js",
12
+ "build:css": "tailwindcss -i test/fixtures/tailwind.css -o test/tmp/rails_datepicker.css --minify"
13
+ },
14
+ "peerDependencies": {
15
+ "@hotwired/stimulus": "^3.2.0"
16
+ },
17
+ "devDependencies": {
18
+ "@hotwired/stimulus": "3.2.2",
19
+ "@tailwindcss/cli": "^4.3.0",
20
+ "tailwindcss": "^4.3.0"
21
+ }
22
+ }