ruby_ui 1.2.0 → 1.4.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -0
  3. data/lib/generators/ruby_ui/component/all_generator.rb +6 -4
  4. data/lib/generators/ruby_ui/component_generator.rb +51 -31
  5. data/lib/generators/ruby_ui/dependencies.yml +32 -10
  6. data/lib/generators/ruby_ui/install/templates/tailwind.css.erb +1 -1
  7. data/lib/generators/ruby_ui/javascript_utils.rb +24 -7
  8. data/lib/ruby_ui/accordion/accordion_content.rb +4 -2
  9. data/lib/ruby_ui/accordion/accordion_controller.js +19 -4
  10. data/lib/ruby_ui/avatar/avatar.rb +3 -0
  11. data/lib/ruby_ui/avatar/avatar_controller.js +33 -0
  12. data/lib/ruby_ui/avatar/avatar_fallback.rb +3 -0
  13. data/lib/ruby_ui/avatar/avatar_image.rb +9 -1
  14. data/lib/ruby_ui/base.rb +6 -0
  15. data/lib/ruby_ui/calendar/calendar.rb +3 -1
  16. data/lib/ruby_ui/calendar/calendar_controller.js +66 -7
  17. data/lib/ruby_ui/calendar/calendar_days.rb +20 -0
  18. data/lib/ruby_ui/calendar/calendar_docs.rb +9 -0
  19. data/lib/ruby_ui/combobox/combobox.rb +1 -7
  20. data/lib/ruby_ui/combobox/combobox_checkbox.rb +7 -1
  21. data/lib/ruby_ui/combobox/combobox_controller.js +56 -244
  22. data/lib/ruby_ui/combobox/combobox_item.rb +7 -5
  23. data/lib/ruby_ui/combobox/combobox_list_group.rb +1 -1
  24. data/lib/ruby_ui/combobox/combobox_popover.rb +5 -0
  25. data/lib/ruby_ui/combobox/combobox_radio.rb +8 -1
  26. data/lib/ruby_ui/combobox/combobox_toggle_all_checkbox.rb +7 -1
  27. data/lib/ruby_ui/combobox/combobox_trigger.rb +19 -19
  28. data/lib/ruby_ui/command/command_controller.js +10 -19
  29. data/lib/ruby_ui/command/command_dialog.rb +4 -1
  30. data/lib/ruby_ui/command/command_dialog_content.rb +2 -2
  31. data/lib/ruby_ui/command/command_dialog_controller.js +34 -0
  32. data/lib/ruby_ui/command/command_dialog_trigger.rb +2 -2
  33. data/lib/ruby_ui/data_table/data_table_docs.rb +1 -1
  34. data/lib/ruby_ui/date_picker/date_picker.rb +85 -0
  35. data/lib/ruby_ui/date_picker/date_picker_docs.rb +23 -0
  36. data/lib/ruby_ui/dialog/dialog_content.rb +7 -19
  37. data/lib/ruby_ui/dialog/dialog_controller.js +22 -10
  38. data/lib/ruby_ui/dropdown_menu/dropdown_menu_item.rb +10 -4
  39. data/lib/ruby_ui/form/form_docs.rb +89 -0
  40. data/lib/ruby_ui/masked_input/masked_input.rb +1 -11
  41. data/lib/ruby_ui/masked_input/masked_input_controller.js +0 -13
  42. data/lib/ruby_ui/select/select_value.rb +2 -1
  43. data/lib/ruby_ui/sheet/sheet.rb +9 -1
  44. data/lib/ruby_ui/sheet/sheet_controller.js +6 -0
  45. data/lib/ruby_ui/table/table_docs.rb +2 -2
  46. data/lib/ruby_ui/tabs/tabs_docs.rb +1 -1
  47. data/lib/ruby_ui/tabs/tabs_trigger.rb +10 -4
  48. data/lib/ruby_ui/theme_toggle/theme_toggle.rb +14 -2
  49. data/lib/ruby_ui/theme_toggle/theme_toggle_controller.js +27 -19
  50. data/lib/ruby_ui/theme_toggle/theme_toggle_docs.rb +12 -42
  51. data/lib/ruby_ui/toast/toast.rb +18 -0
  52. data/lib/ruby_ui/toast/toast_action.rb +27 -0
  53. data/lib/ruby_ui/toast/toast_cancel.rb +27 -0
  54. data/lib/ruby_ui/toast/toast_close.rb +40 -0
  55. data/lib/ruby_ui/toast/toast_controller.js +151 -0
  56. data/lib/ruby_ui/toast/toast_description.rb +18 -0
  57. data/lib/ruby_ui/toast/toast_docs.rb +12 -0
  58. data/lib/ruby_ui/toast/toast_icon.rb +65 -0
  59. data/lib/ruby_ui/toast/toast_item.rb +72 -0
  60. data/lib/ruby_ui/toast/toast_region.rb +124 -0
  61. data/lib/ruby_ui/toast/toast_title.rb +18 -0
  62. data/lib/ruby_ui/toast/toaster_controller.js +306 -0
  63. data/lib/ruby_ui/toggle/toggle.rb +101 -0
  64. data/lib/ruby_ui/toggle/toggle_controller.js +33 -0
  65. data/lib/ruby_ui/toggle_group/toggle_group.rb +119 -0
  66. data/lib/ruby_ui/toggle_group/toggle_group_controller.js +126 -0
  67. data/lib/ruby_ui/toggle_group/toggle_group_item.rb +67 -0
  68. data/lib/ruby_ui/tooltip/tooltip_content.rb +12 -5
  69. data/lib/ruby_ui/tooltip/tooltip_controller.js +58 -22
  70. data/lib/ruby_ui/tooltip/tooltip_docs.rb +13 -0
  71. data/lib/ruby_ui/tooltip/tooltip_trigger.rb +10 -3
  72. data/lib/ruby_ui.rb +3 -1
  73. metadata +30 -14
  74. data/lib/ruby_ui/theme_toggle/set_dark_mode.rb +0 -16
  75. data/lib/ruby_ui/theme_toggle/set_light_mode.rb +0 -16
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 488fdc5b97a189ce4916f776af376a06760255437f681b3e3ef7e9aa0998e2b3
4
- data.tar.gz: ff44a35b46e45d1cf1b510716fdc174fc50e229ef7a82d81c8318d51911beb3d
3
+ metadata.gz: c8dd00d73934f8ff9e2ac4e92994de33c910192f92df634def1e1726bde86393
4
+ data.tar.gz: 50ba081c5c25aa85581620726935ca1469b402751eade75cdb8adbc2042160d5
5
5
  SHA512:
6
- metadata.gz: 7f7fe5941b4a9e4375d280efd31ab1e35dca88b699cf4580f6722294c4b8fe161240bbe7bce09bf5f75d5a41a9c2d62e6c24ab9a3b4d4f32ef8062fa4e81d5b3
7
- data.tar.gz: 0b3e785f7380c7ba5abc0e721d5d17c3a5708a554b13bb2cd946e8019241642ebd7dfb1b3cfc46b759d14e18e37d82cbf0f874824a0489ddf8d09003bda1d9d1
6
+ metadata.gz: 44150fd27560c0b3a7d5193501abfd755e36a15ae74dc555088b9a30a1b228adc521dd8a82fc705e3c2396a45bb0baf48aed1351840d96aaa87eae6a9c2d545d
7
+ data.tar.gz: fc536b32afeabc805ad4815ce9be98090caf2402e6c5cc920011d9166b721690eb35938195b5079bf44b7400c4ec5454642f00ea844df22062ef326e32316bc4
data/README.md CHANGED
@@ -55,6 +55,12 @@ You can generate your components using `ruby_ui:component` generator.
55
55
  bin/rails g ruby_ui:component Accordion
56
56
  ```
57
57
 
58
+ You can also generate multiple components at once.
59
+
60
+ ```bash
61
+ bin/rails g ruby_ui:component Button Link Input Textarea
62
+ ```
63
+
58
64
  You also can generate all components using `ruby_ui:component:all` generator
59
65
 
60
66
  ## Documentation 📖
@@ -10,11 +10,13 @@ module RubyUI
10
10
  def generate_components
11
11
  say "Generating all components..."
12
12
 
13
- Dir.children(self.class.source_root).each do |folder_name|
14
- next if folder_name.ends_with?(".rb")
15
-
16
- run "bin/rails generate ruby_ui:component #{folder_name} --force #{options["force"]}"
13
+ # Each component lives in its own directory; select directories only so stray
14
+ # files (e.g. base.rb or a macOS .DS_Store) are never passed as component names.
15
+ folder_names = Dir.children(self.class.source_root).select do |entry|
16
+ File.directory?(File.join(self.class.source_root, entry))
17
17
  end
18
+
19
+ run "bin/rails generate ruby_ui:component #{folder_names.join(" ")} --force #{options["force"]}"
18
20
  end
19
21
  end
20
22
  end
@@ -7,69 +7,89 @@ module RubyUI
7
7
  namespace "ruby_ui:component"
8
8
 
9
9
  source_root File.expand_path("../../ruby_ui", __dir__)
10
- argument :component_name, type: :string, required: true
10
+ argument :component_names, type: :array, required: true, banner: "Button Link Input"
11
11
  class_option :force, type: :boolean, default: false
12
12
  class_option :with_docs, type: :boolean, default: false
13
13
 
14
- def generate_component
15
- if component_not_found?
16
- say "Component not found: #{component_name}", :red
17
- exit
14
+ def generate_components
15
+ validate_components!
16
+
17
+ component_names.each do |component_name|
18
+ say "Generating #{component_name} files..."
19
+ copy_related_component_files(component_name)
20
+ copy_js_files(component_name)
21
+ install_dependencies(component_name)
18
22
  end
19
23
 
20
- say "Generating #{component_name} files..."
24
+ update_stimulus_manifest
21
25
  end
22
26
 
23
- def copy_related_component_files
27
+ private
28
+
29
+ def validate_components!
30
+ missing = component_names.reject { |name| component_exists?(name) }
31
+ return if missing.empty?
32
+
33
+ say "Component(s) not found: #{missing.join(", ")}", :red
34
+ exit 1
35
+ end
36
+
37
+ def copy_related_component_files(component_name)
24
38
  say "Generating components"
25
39
 
26
- components_file_paths.each do |file_path|
40
+ components_file_paths(component_name).each do |file_path|
27
41
  component_file_name = file_path.split("/").last
28
- copy_file file_path, Rails.root.join("app/components/ruby_ui", component_folder_name, component_file_name), force: options["force"]
42
+ copy_file file_path, Rails.root.join("app/components/ruby_ui", component_folder_name(component_name), component_file_name), force: options["force"]
29
43
  end
30
44
  end
31
45
 
32
- def copy_js_files
33
- return if js_controller_file_paths.empty?
46
+ def copy_js_files(component_name)
47
+ paths = js_controller_file_paths(component_name)
48
+ return if paths.empty?
34
49
 
35
50
  say "Generating Stimulus controllers"
36
51
 
37
- js_controller_file_paths.each do |file_path|
52
+ paths.each do |file_path|
38
53
  controller_file_name = file_path.split("/").last
39
54
  copy_file file_path, Rails.root.join("app/javascript/controllers/ruby_ui", controller_file_name), force: options["force"]
40
55
  end
41
56
 
57
+ @stimulus_controllers_added = true
58
+ end
59
+
60
+ def update_stimulus_manifest
61
+ return unless @stimulus_controllers_added
62
+
42
63
  # Importmap doesn't have controller manifest, instead it uses `eagerLoadControllersFrom("controllers", application)`
43
- if !using_importmap?
44
- say "Updating Stimulus controllers manifest"
45
- run "rake stimulus:manifest:update"
46
- end
64
+ return if using_importmap?
65
+
66
+ say "Updating Stimulus controllers manifest"
67
+ run "rake stimulus:manifest:update"
47
68
  end
48
69
 
49
- def install_dependencies
50
- return if dependencies.blank?
70
+ def install_dependencies(component_name)
71
+ deps = dependencies(component_name)
72
+ return if deps.blank?
51
73
 
52
74
  say "Installing dependencies"
53
75
 
54
- install_components_dependencies(dependencies["components"])
55
- install_gems_dependencies(dependencies["gems"])
56
- install_js_packages(dependencies["js_packages"])
76
+ install_components_dependencies(deps["components"])
77
+ install_gems_dependencies(deps["gems"])
78
+ install_js_packages(deps["js_packages"])
57
79
  end
58
80
 
59
- private
60
-
61
- def component_not_found? = !Dir.exist?(component_folder_path)
81
+ def component_exists?(component_name) = Dir.exist?(component_folder_path(component_name))
62
82
 
63
- def component_folder_name = component_name.underscore
83
+ def component_folder_name(component_name) = component_name.underscore
64
84
 
65
- def component_folder_path = File.join(self.class.source_root, component_folder_name)
85
+ def component_folder_path(component_name) = File.join(self.class.source_root, component_folder_name(component_name))
66
86
 
67
- def components_file_paths
68
- files = Dir.glob(File.join(component_folder_path, "*.rb"))
87
+ def components_file_paths(component_name)
88
+ files = Dir.glob(File.join(component_folder_path(component_name), "*.rb"))
69
89
  options["with_docs"] ? files : files.reject { |f| f.end_with?("_docs.rb") }
70
90
  end
71
91
 
72
- def js_controller_file_paths = Dir.glob(File.join(component_folder_path, "*.js"))
92
+ def js_controller_file_paths(component_name) = Dir.glob(File.join(component_folder_path(component_name), "*.js"))
73
93
 
74
94
  def install_components_dependencies(components)
75
95
  components&.each do |component|
@@ -89,10 +109,10 @@ module RubyUI
89
109
  end
90
110
  end
91
111
 
92
- def dependencies
112
+ def dependencies(component_name)
93
113
  @dependencies ||= YAML.load_file(File.join(__dir__, "dependencies.yml")).freeze
94
114
 
95
- @dependencies[component_folder_name]
115
+ @dependencies[component_folder_name(component_name)]
96
116
  end
97
117
  end
98
118
  end
@@ -2,16 +2,6 @@ accordion:
2
2
  js_packages:
3
3
  - "motion"
4
4
 
5
- data_table:
6
- components:
7
- - "Table"
8
- - "Checkbox"
9
- - "NativeSelect"
10
- - "Pagination"
11
- - "DropdownMenu"
12
- - "Input"
13
- - "Button"
14
-
15
5
  alert_dialog:
16
6
  components:
17
7
  - "Button"
@@ -52,6 +42,22 @@ context_menu:
52
42
  js_packages:
53
43
  - "tippy.js"
54
44
 
45
+ date_picker:
46
+ components:
47
+ - "Input"
48
+ - "Popover"
49
+ - "Calendar"
50
+
51
+ data_table:
52
+ components:
53
+ - "Button"
54
+ - "Checkbox"
55
+ - "DropdownMenu"
56
+ - "Input"
57
+ - "NativeSelect"
58
+ - "Pagination"
59
+ - "Table"
60
+
55
61
  dropdown_menu:
56
62
  js_packages:
57
63
  - "@floating-ui/dom"
@@ -79,6 +85,22 @@ select:
79
85
  js_packages:
80
86
  - "@floating-ui/dom"
81
87
 
88
+ toast:
89
+ js_packages: []
90
+
82
91
  tooltip:
92
+ components:
93
+ - "Typography"
94
+
83
95
  js_packages:
84
96
  - "@floating-ui/dom"
97
+
98
+ toggle: {}
99
+
100
+ toggle_group:
101
+ components:
102
+ - "Toggle"
103
+
104
+ theme_toggle:
105
+ components:
106
+ - "Toggle"
@@ -1,7 +1,7 @@
1
1
  @import "tailwindcss";
2
2
 
3
3
  <% if using_importmap? %>
4
- @import "../../../vendor/javascript/tw-animate-css.js";
4
+ @import "../../../vendor/javascript/tw-animate-css.css";
5
5
  <% else %>
6
6
  @import "tw-animate-css";
7
7
  <% end %>
@@ -1,6 +1,8 @@
1
1
  module RubyUI
2
2
  module Generators
3
3
  module JavascriptUtils
4
+ TW_ANIMATE_CSS_VERSION = "1.4.0"
5
+
4
6
  def install_js_package(package)
5
7
  if using_importmap?
6
8
  pin_with_importmap(package)
@@ -21,6 +23,8 @@ module RubyUI
21
23
  case package
22
24
  when "motion"
23
25
  pin_motion
26
+ when "tw-animate-css"
27
+ pin_tw_animate_css
24
28
  when "tippy.js"
25
29
  pin_tippy_js
26
30
  else
@@ -29,23 +33,34 @@ module RubyUI
29
33
  end
30
34
 
31
35
  def using_importmap?
32
- File.exist?(Rails.root.join("config/importmap.rb")) && File.exist?(Rails.root.join("bin/importmap"))
36
+ File.exist?(rails_root.join("config/importmap.rb")) && File.exist?(rails_root.join("bin/importmap"))
33
37
  end
34
38
 
35
- def using_bun? = File.exist?(Rails.root.join("bun.lock"))
39
+ def using_bun? = File.exist?(rails_root.join("bun.lock"))
40
+
41
+ def using_npm? = File.exist?(rails_root.join("package-lock.json"))
36
42
 
37
- def using_npm? = File.exist?(Rails.root.join("package-lock.json"))
43
+ def using_pnpm? = File.exist?(rails_root.join("pnpm-lock.yaml"))
38
44
 
39
- def using_pnpm? = File.exist?(Rails.root.join("pnpm-lock.yaml"))
45
+ def using_yarn? = File.exist?(rails_root.join("yarn.lock"))
40
46
 
41
- def using_yarn? = File.exist?(Rails.root.join("yarn.lock"))
47
+ def pin_tw_animate_css
48
+ say <<~TEXT
49
+ WARNING: Installing tw-animate-css as a CSS asset because Importmap cannot pin CSS-only package exports.
50
+ TEXT
51
+
52
+ empty_directory rails_root.join("vendor/javascript")
53
+ # CDN serves "tw-animate.css"; we save as "tw-animate-css.css" to match package name. Do not "correct" the URL.
54
+ get "https://cdn.jsdelivr.net/npm/tw-animate-css@#{TW_ANIMATE_CSS_VERSION}/dist/tw-animate.css",
55
+ rails_root.join("vendor/javascript/tw-animate-css.css")
56
+ end
42
57
 
43
58
  def pin_motion
44
59
  say <<~TEXT
45
60
  WARNING: Installing motion from CDN because `bin/importmap pin motion` doesn't download the correct file.
46
61
  TEXT
47
62
 
48
- inject_into_file Rails.root.join("config/importmap.rb"), <<~RUBY
63
+ inject_into_file rails_root.join("config/importmap.rb"), <<~RUBY
49
64
  pin "motion", to: "https://cdn.jsdelivr.net/npm/motion@11.11.17/+esm"\n
50
65
  RUBY
51
66
  end
@@ -55,11 +70,13 @@ module RubyUI
55
70
  WARNING: Installing tippy.js from CDN because `bin/importmap pin tippy.js` doesn't download the correct file.
56
71
  TEXT
57
72
 
58
- inject_into_file Rails.root.join("config/importmap.rb"), <<~RUBY
73
+ inject_into_file rails_root.join("config/importmap.rb"), <<~RUBY
59
74
  pin "tippy.js", to: "https://cdn.jsdelivr.net/npm/tippy.js@6.3.7/+esm"
60
75
  pin "@popperjs/core", to: "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/+esm"\n
61
76
  RUBY
62
77
  end
78
+
79
+ def rails_root = Rails.root
63
80
  end
64
81
  end
65
82
  end
@@ -11,10 +11,12 @@ module RubyUI
11
11
  def default_attrs
12
12
  {
13
13
  data: {
14
- ruby_ui__accordion_target: "content"
14
+ ruby_ui__accordion_target: "content",
15
+ state: "closed"
15
16
  },
16
17
  class: "overflow-y-hidden",
17
- style: "height: 0px;"
18
+ style: "height: 0px;",
19
+ hidden: true
18
20
  }
19
21
  end
20
22
  end
@@ -65,9 +65,15 @@ export default class extends Controller {
65
65
 
66
66
  // Reveal the accordion content with animation
67
67
  revealContent() {
68
- const contentHeight = this.contentTarget.scrollHeight;
68
+ const content = this.contentTarget;
69
+
70
+ // Remove hidden so the element participates in layout before measuring
71
+ content.removeAttribute("hidden");
72
+ content.dataset.state = "open";
73
+
74
+ const contentHeight = content.scrollHeight;
69
75
  animate(
70
- this.contentTarget,
76
+ content,
71
77
  { height: `${contentHeight}px` },
72
78
  {
73
79
  duration: this.animationDurationValue,
@@ -78,14 +84,23 @@ export default class extends Controller {
78
84
 
79
85
  // Hide the accordion content with animation
80
86
  hideContent() {
87
+ const content = this.contentTarget;
88
+ content.dataset.state = "closed";
89
+
81
90
  animate(
82
- this.contentTarget,
91
+ content,
83
92
  { height: 0 },
84
93
  {
85
94
  duration: this.animationDurationValue,
86
95
  easing: this.animationEasingValue,
87
96
  },
88
- );
97
+ ).finished.then(() => {
98
+ // After animation completes, truly hide the element so it is removed
99
+ // from layout and form focus — prevents trapped validation errors
100
+ if (content.dataset.state === "closed") {
101
+ content.setAttribute("hidden", "");
102
+ }
103
+ });
89
104
  }
90
105
 
91
106
  // Rotate the accordion icon 180deg using animate function
@@ -24,6 +24,9 @@ module RubyUI
24
24
 
25
25
  def default_attrs
26
26
  {
27
+ data: {
28
+ controller: "ruby-ui--avatar"
29
+ },
27
30
  class: ["relative flex shrink-0 overflow-hidden rounded-full", @size_classes]
28
31
  }
29
32
  end
@@ -0,0 +1,33 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static targets = ["image", "fallback"];
5
+
6
+ connect() {
7
+ if (!this.hasImageTarget) {
8
+ return;
9
+ }
10
+
11
+ if (this.imageTarget.complete && this.imageTarget.naturalWidth > 0) {
12
+ this.showImage();
13
+ } else {
14
+ // Image not yet loaded (or failed): hide it so the fallback shows.
15
+ // Image visibility is restored by the load/error handlers.
16
+ this.showFallback();
17
+ }
18
+ }
19
+
20
+ showImage() {
21
+ this.imageTargets.forEach((image) => image.classList.remove("hidden"));
22
+ this.fallbackTargets.forEach((fallback) =>
23
+ fallback.classList.add("hidden"),
24
+ );
25
+ }
26
+
27
+ showFallback() {
28
+ this.imageTargets.forEach((image) => image.classList.add("hidden"));
29
+ this.fallbackTargets.forEach((fallback) =>
30
+ fallback.classList.remove("hidden"),
31
+ );
32
+ }
33
+ }
@@ -10,6 +10,9 @@ module RubyUI
10
10
 
11
11
  def default_attrs
12
12
  {
13
+ data: {
14
+ ruby_ui__avatar_target: "fallback"
15
+ },
13
16
  class: "flex h-full w-full items-center justify-center rounded-full bg-muted"
14
17
  }
15
18
  end
@@ -16,7 +16,15 @@ module RubyUI
16
16
 
17
17
  def default_attrs
18
18
  {
19
- loading: "lazy",
19
+ # NB: do not set loading: "lazy" here. avatar_controller hides a not-yet-loaded
20
+ # image with `display:none` (the `hidden` class) so the fallback shows. The
21
+ # browser never fetches a `loading="lazy"` image that generates no box, so its
22
+ # `load` event never fires and the image stays hidden forever (#415). shadcn/radix
23
+ # do not lazy-load the avatar image either.
24
+ data: {
25
+ ruby_ui__avatar_target: "image",
26
+ action: "load->ruby-ui--avatar#showImage error->ruby-ui--avatar#showFallback"
27
+ },
20
28
  class: "aspect-square h-full w-full",
21
29
  alt: @alt,
22
30
  src: @src
data/lib/ruby_ui/base.rb CHANGED
@@ -18,5 +18,11 @@ module RubyUI
18
18
  def default_attrs
19
19
  {}
20
20
  end
21
+
22
+ if defined?(Rails) && Rails.env.development?
23
+ def before_template
24
+ comment { "Before #{self.class.name}" }
25
+ end
26
+ end
21
27
  end
22
28
  end
@@ -2,8 +2,9 @@
2
2
 
3
3
  module RubyUI
4
4
  class Calendar < Base
5
- def initialize(selected_date: nil, input_id: nil, date_format: "yyyy-MM-dd", **attrs)
5
+ def initialize(selected_date: nil, min_date: nil, input_id: nil, date_format: "yyyy-MM-dd", **attrs)
6
6
  @selected_date = selected_date
7
+ @min_date = min_date
7
8
  @input_id = input_id
8
9
  @date_format = date_format
9
10
  super(**attrs)
@@ -30,6 +31,7 @@ module RubyUI
30
31
  data: {
31
32
  controller: "ruby-ui--calendar",
32
33
  ruby_ui__calendar_selected_date_value: @selected_date&.to_s,
34
+ ruby_ui__calendar_min_date_value: @min_date&.to_s,
33
35
  ruby_ui__calendar_format_value: @date_format,
34
36
  ruby_ui__calendar_ruby_ui__calendar_input_outlet: @input_id
35
37
  }
@@ -6,6 +6,7 @@ export default class extends Controller {
6
6
  "calendar",
7
7
  "title",
8
8
  "weekdaysTemplate",
9
+ "disabledDateTemplate",
9
10
  "selectedDateTemplate",
10
11
  "todayDateTemplate",
11
12
  "currentMonthDateTemplate",
@@ -16,6 +17,10 @@ export default class extends Controller {
16
17
  type: String,
17
18
  default: null,
18
19
  },
20
+ minDate: {
21
+ type: String,
22
+ default: null,
23
+ },
19
24
  viewDate: {
20
25
  type: String,
21
26
  default: new Date().toISOString().slice(0, 10),
@@ -43,13 +48,21 @@ export default class extends Controller {
43
48
 
44
49
  selectDay(e) {
45
50
  e.preventDefault();
51
+ if (this.isDateDisabled(e.currentTarget.dataset.day)) return;
52
+
46
53
  // Set the selected date value
47
54
  this.selectedDateValue = e.currentTarget.dataset.day;
48
55
  }
49
56
 
50
57
  selectedDateValueChanged(value, prevValue) {
58
+ const selectedDate = this.selectedDate();
59
+ if (!selectedDate) {
60
+ this.updateCalendar();
61
+ return;
62
+ }
63
+
51
64
  // update the viewDateValue to the first day of month of the selected date (This will trigger updateCalendar() function)
52
- const newViewDate = new Date(this.selectedDateValue);
65
+ const newViewDate = new Date(selectedDate);
53
66
  newViewDate.setDate(2); // set the day to the 2nd (to avoid issues with months with different number of days and timezones)
54
67
  this.viewDateValue = newViewDate.toISOString().slice(0, 10);
55
68
 
@@ -58,7 +71,7 @@ export default class extends Controller {
58
71
 
59
72
  // update the input value
60
73
  this.rubyUiCalendarInputOutlets.forEach((outlet) => {
61
- const formattedDate = this.formatDate(this.selectedDate());
74
+ const formattedDate = this.formatDate(selectedDate);
62
75
  outlet.setValue(formattedDate);
63
76
  });
64
77
  }
@@ -101,10 +114,20 @@ export default class extends Controller {
101
114
 
102
115
  renderDay(day) {
103
116
  const today = new Date();
117
+ const selectedDate = this.selectedDate();
104
118
  let dateHTML = "";
105
119
  const data = { day: day, dayDate: day.getDate() };
106
120
 
107
- if (day.toDateString() === this.selectedDate().toDateString()) {
121
+ if (this.isDateDisabled(day)) {
122
+ // disabledDate
123
+ dateHTML = Mustache.render(
124
+ this.disabledDateTemplateTarget.innerHTML,
125
+ data,
126
+ );
127
+ } else if (
128
+ selectedDate &&
129
+ day.toDateString() === selectedDate.toDateString()
130
+ ) {
108
131
  // selectedDate
109
132
  // Render the selected date template target innerHTML with Mustache
110
133
  dateHTML = Mustache.render(
@@ -137,13 +160,13 @@ export default class extends Controller {
137
160
  }
138
161
 
139
162
  selectedDate() {
140
- return new Date(this.selectedDateValue);
163
+ return this.parseDate(this.selectedDateValue);
141
164
  }
142
165
 
143
166
  viewDate() {
144
- return this.viewDateValue
145
- ? new Date(this.viewDateValue)
146
- : this.selectedDate();
167
+ return (
168
+ this.parseDate(this.viewDateValue) || this.selectedDate() || new Date()
169
+ );
147
170
  }
148
171
 
149
172
  getFullWeeksStartAndEndInMonth() {
@@ -246,4 +269,40 @@ export default class extends Controller {
246
269
  return "th";
247
270
  }
248
271
  }
272
+
273
+ minDate() {
274
+ return this.parseDate(this.minDateValue);
275
+ }
276
+
277
+ isDateDisabled(date) {
278
+ const minDate = this.minDate();
279
+ const candidate = this.parseDate(date);
280
+
281
+ if (!minDate || !candidate) return false;
282
+
283
+ return this.startOfDay(candidate) < this.startOfDay(minDate);
284
+ }
285
+
286
+ parseDate(value) {
287
+ if (!value) return null;
288
+ if (value instanceof Date) return new Date(value);
289
+
290
+ const isoDate = value.toString().match(/^(\d{4})-(\d{2})-(\d{2})/);
291
+ if (isoDate) {
292
+ return new Date(
293
+ Number(isoDate[1]),
294
+ Number(isoDate[2]) - 1,
295
+ Number(isoDate[3]),
296
+ );
297
+ }
298
+
299
+ const date = new Date(value);
300
+ return Number.isNaN(date.getTime()) ? null : date;
301
+ }
302
+
303
+ startOfDay(date) {
304
+ const normalizedDate = new Date(date);
305
+ normalizedDate.setHours(0, 0, 0, 0);
306
+ return normalizedDate;
307
+ }
249
308
  }
@@ -5,6 +5,7 @@ module RubyUI
5
5
  BASE_CLASS = "inline-flex items-center justify-center rounded-md text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-8 w-8 p-0 font-normal aria-selected:opacity-100"
6
6
 
7
7
  def view_template
8
+ render_disabled_date_template
8
9
  render_selected_date_template
9
10
  render_today_date_template
10
11
  render_current_month_date_template
@@ -13,6 +14,25 @@ module RubyUI
13
14
 
14
15
  private
15
16
 
17
+ def render_disabled_date_template
18
+ date_template("disabledDateTemplate") do
19
+ button(
20
+ data_day: "{{day}}",
21
+ name: "day",
22
+ class:
23
+ [
24
+ BASE_CLASS,
25
+ "cursor-not-allowed bg-background text-muted-foreground hover:bg-background hover:text-muted-foreground"
26
+ ],
27
+ disabled: true,
28
+ role: "gridcell",
29
+ tabindex: "-1",
30
+ type: "button",
31
+ aria_disabled: "true"
32
+ ) { "{{dayDate}}" }
33
+ end
34
+ end
35
+
16
36
  def render_selected_date_template
17
37
  date_template("selectedDateTemplate") do
18
38
  button(
@@ -26,6 +26,15 @@ class Views::Docs::Calendar < Views::Base
26
26
  RUBY
27
27
  end
28
28
 
29
+ render Docs::VisualCodeExample.new(title: "Minimum date", description: "Disable dates before a given date", context: self) do
30
+ <<~RUBY
31
+ div(class: 'space-y-4') do
32
+ Input(type: 'string', placeholder: "Select a date", class: 'rounded-md border shadow', id: 'minimum-date', data_controller: 'ruby-ui--calendar-input')
33
+ Calendar(input_id: '#minimum-date', min_date: Date.current, class: 'rounded-md border shadow')
34
+ end
35
+ RUBY
36
+ end
37
+
29
38
  render Components::ComponentSetup::Tabs.new(component_name: component)
30
39
 
31
40
  render Docs::ComponentsTable.new(component_files(component))