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,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,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,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
|
+
}
|