ruby_ui 1.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 41271e83392afeb5499d950f64ea1e5513c3694fac4521c7599f3ac1c281c979
4
- data.tar.gz: 5370ed6872556cf2848763b09d6edddecc2b5b822357c3af35e6bbfc1eb07ae0
3
+ metadata.gz: c8dd00d73934f8ff9e2ac4e92994de33c910192f92df634def1e1726bde86393
4
+ data.tar.gz: 50ba081c5c25aa85581620726935ca1469b402751eade75cdb8adbc2042160d5
5
5
  SHA512:
6
- metadata.gz: e3694f2acd18f037160228f955f019c20696543b5047f87cb1a3108a272982b69c188bbbec174063add1881c29d3625f0fde34d7fe8d9dfebe9bb29621986831
7
- data.tar.gz: f0853192ca6871211535ac8e60e1db1dfae5adcba4fe60e4284f147bfe6296ce0ded60436764ff3829db5d20c874f3efcd7f68a56f47f411900f8c95f8d6dac3
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
@@ -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
@@ -16,7 +16,11 @@ 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.
20
24
  data: {
21
25
  ruby_ui__avatar_target: "image",
22
26
  action: "load->ruby-ui--avatar#showImage error->ruby-ui--avatar#showFallback"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Views::Docs::DataTable < Views::Base
4
- Row = Struct.new(:id, :name, :email, :salary, :status, keyword_init: true)
4
+ Row = Struct.new(:id, :name, :email, :salary, :status)
5
5
 
6
6
  SAMPLE_ROWS = [
7
7
  Row.new(id: 1, name: "Alice", email: "alice@example.com", salary: 90_000, status: "Active"),
@@ -17,14 +17,9 @@ module RubyUI
17
17
  end
18
18
 
19
19
  def view_template
20
- template(data: {ruby_ui__dialog_target: "content"}) do
21
- div(data_controller: "ruby-ui--dialog") do
22
- backdrop
23
- div(**attrs) do
24
- yield
25
- close_button
26
- end
27
- end
20
+ dialog(**attrs) do
21
+ yield
22
+ close_button
28
23
  end
29
24
  end
30
25
 
@@ -32,9 +27,10 @@ module RubyUI
32
27
 
33
28
  def default_attrs
34
29
  {
35
- data_state: "open",
30
+ data_ruby_ui__dialog_target: "dialog",
31
+ data_action: "click->ruby-ui--dialog#backdropClick",
36
32
  class: [
37
- "fixed flex flex-col pointer-events-auto left-[50%] top-[50%] z-50 w-full max-h-screen overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:rounded-lg md:w-full",
33
+ "fixed flex flex-col pointer-events-auto left-[50%] top-[50%] z-50 w-full max-h-screen overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 backdrop:bg-background/80 backdrop:backdrop-blur-sm open:animate-in open:fade-in-0 open:zoom-in-95 sm:rounded-lg md:w-full",
38
34
  SIZES[@size]
39
35
  ]
40
36
  }
@@ -43,7 +39,7 @@ module RubyUI
43
39
  def close_button
44
40
  button(
45
41
  type: "button",
46
- class: "absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
42
+ class: "absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none",
47
43
  data_action: "click->ruby-ui--dialog#dismiss"
48
44
  ) do
49
45
  svg(
@@ -65,13 +61,5 @@ module RubyUI
65
61
  span(class: "sr-only") { "Close" }
66
62
  end
67
63
  end
68
-
69
- def backdrop
70
- div(
71
- data_state: "open",
72
- data_action: "click->ruby-ui--dialog#dismiss esc->ruby-ui--dialog#dismiss",
73
- class: "fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=open]:fade-in-0"
74
- )
75
- end
76
64
  end
77
65
  end
@@ -1,8 +1,8 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
- // Connects to data-controller="dialog"
3
+ // Connects to data-controller="ruby-ui--dialog"
4
4
  export default class extends Controller {
5
- static targets = ["content"]
5
+ static targets = ["dialog"]
6
6
  static values = {
7
7
  open: {
8
8
  type: Boolean,
@@ -11,22 +11,34 @@ export default class extends Controller {
11
11
  }
12
12
 
13
13
  connect() {
14
+ this.dialogTarget.addEventListener("close", this.handleClose)
14
15
  if (this.openValue) {
15
16
  this.open()
16
17
  }
17
18
  }
18
19
 
20
+ disconnect() {
21
+ this.dialogTarget.removeEventListener("close", this.handleClose)
22
+ document.body.classList.remove("overflow-hidden")
23
+ }
24
+
19
25
  open(e) {
20
- e?.preventDefault();
21
- document.body.insertAdjacentHTML('beforeend', this.contentTarget.innerHTML)
22
- // prevent scroll on body
23
- document.body.classList.add('overflow-hidden')
26
+ e?.preventDefault()
27
+ this.dialogTarget.showModal()
28
+ document.body.classList.add("overflow-hidden")
24
29
  }
25
30
 
26
31
  dismiss() {
27
- // allow scroll on body
28
- document.body.classList.remove('overflow-hidden')
29
- // remove the element
30
- this.element.remove()
32
+ this.dialogTarget.close()
33
+ }
34
+
35
+ backdropClick(e) {
36
+ if (e.target === this.dialogTarget) {
37
+ this.dismiss()
38
+ }
39
+ }
40
+
41
+ handleClose = () => {
42
+ document.body.classList.remove("overflow-hidden")
31
43
  }
32
44
  }
@@ -2,20 +2,24 @@
2
2
 
3
3
  module RubyUI
4
4
  class DropdownMenuItem < Base
5
- def initialize(href: "#", **attrs)
5
+ def initialize(as: :a, href: "#", **attrs)
6
+ @as = as
6
7
  @href = href
7
8
  super(**attrs)
8
9
  end
9
10
 
10
11
  def view_template(&)
11
- a(**attrs, &)
12
+ if @as == :div
13
+ div(**attrs, &)
14
+ else
15
+ a(**attrs, &)
16
+ end
12
17
  end
13
18
 
14
19
  private
15
20
 
16
21
  def default_attrs
17
- {
18
- href: @href,
22
+ base = {
19
23
  role: "menuitem",
20
24
  class: "relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
21
25
  data_action: "click->ruby-ui--dropdown-menu#close",
@@ -23,6 +27,8 @@ module RubyUI
23
27
  tabindex: "-1",
24
28
  data_orientation: "vertical"
25
29
  }
30
+ base[:href] = @href unless @as == :div
31
+ base
26
32
  end
27
33
  end
28
34
  end
@@ -170,6 +170,95 @@ class Views::Docs::Form < Views::Base
170
170
  RUBY
171
171
  end
172
172
 
173
+ Heading(level: 2) { "Rails Integration" }
174
+
175
+ Text do
176
+ plain "RubyUI Form components are plain HTML — they work with any form submission strategy. "
177
+ plain "The recommended approach for Rails apps is to use "
178
+ InlineLink(href: "https://api.rubyonrails.org/classes/ActionView/Helpers/FormHelper.html#method-i-form_with", target: "_blank") { "form_with" }
179
+ plain " to generate the "
180
+ code(class: "font-mono text-sm") { "action" }
181
+ plain " URL and CSRF token, then pass explicit "
182
+ code(class: "font-mono text-sm") { "name" }
183
+ plain " / "
184
+ code(class: "font-mono text-sm") { "id" }
185
+ plain " attributes to each RubyUI input so the browser serialises them correctly. "
186
+ plain "Server-side errors can be surfaced by rendering "
187
+ code(class: "font-mono text-sm") { "FormFieldError" }
188
+ plain " with content from "
189
+ code(class: "font-mono text-sm") { "model.errors.full_messages_for(:attr)" }
190
+ plain "."
191
+ end
192
+
193
+ Heading(level: 3) { "Minimal Rails form" }
194
+ Codeblock(<<~RUBY, syntax: :ruby)
195
+ # In your Phlex view, call form_with via helpers:
196
+ # form_with(url: users_path, method: :post) passes action + CSRF automatically.
197
+ #
198
+ # You can also set action and the CSRF token manually:
199
+ Form(action: helpers.users_path, method: "post", class: "w-2/3 space-y-6") do
200
+ input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token)
201
+
202
+ FormField do
203
+ FormFieldLabel(for: "user_email") { "Email" }
204
+ Input(
205
+ type: "email",
206
+ id: "user_email",
207
+ name: "user[email]",
208
+ placeholder: "you@example.com",
209
+ required: true
210
+ )
211
+ FormFieldError()
212
+ end
213
+
214
+ Button(type: "submit") { "Continue" }
215
+ end
216
+ RUBY
217
+
218
+ Heading(level: 3) { "Devise-style login form" }
219
+ Codeblock(<<~RUBY, syntax: :ruby)
220
+ # Full sign-in form mirroring Devise session[email] / session[password] params.
221
+ # Pass backend errors (e.g. "Invalid email or password") into FormFieldError.
222
+ Form(action: helpers.user_session_path, method: "post", class: "space-y-6") do
223
+ input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token)
224
+
225
+ FormField do
226
+ FormFieldLabel(for: "session_email") { "Email" }
227
+ Input(
228
+ type: "email",
229
+ id: "session_email",
230
+ name: "session[email]",
231
+ placeholder: "you@example.com",
232
+ autocomplete: "email",
233
+ required: true
234
+ )
235
+ FormFieldError { @error_message }
236
+ end
237
+
238
+ FormField do
239
+ FormFieldLabel(for: "session_password") { "Password" }
240
+ Input(
241
+ type: "password",
242
+ id: "session_password",
243
+ name: "session[password]",
244
+ autocomplete: "current-password",
245
+ required: true,
246
+ minlength: "8"
247
+ )
248
+ FormFieldError()
249
+ end
250
+
251
+ FormField do
252
+ div(class: "flex items-center gap-2") do
253
+ Checkbox(id: "session_remember_me", name: "session[remember_me]", value: "1")
254
+ FormFieldLabel(for: "session_remember_me") { "Remember me" }
255
+ end
256
+ end
257
+
258
+ Button(type: "submit", class: "w-full") { "Sign in" }
259
+ end
260
+ RUBY
261
+
173
262
  render Components::ComponentSetup::Tabs.new(component_name: component)
174
263
 
175
264
  render Docs::ComponentsTable.new(component_files(component))
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Views::Docs::Table < Views::Base
4
- Invoice = Struct.new(:identifier, :status, :method, :amount, keyword_init: true)
5
- User = Struct.new(:avatar_url, :name, :username, :commits, :github_url, keyword_init: true)
4
+ Invoice = Struct.new(:identifier, :status, :method, :amount)
5
+ User = Struct.new(:avatar_url, :name, :username, :commits, :github_url)
6
6
 
7
7
  def view_template
8
8
  component = "Table"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Views::Docs::Tabs < Views::Base
4
- Repo = Struct.new(:github_url, :name, :stars, :version, keyword_init: true)
4
+ Repo = Struct.new(:github_url, :name, :stars, :version)
5
5
 
6
6
  def view_template
7
7
  component = "Tabs"
@@ -2,20 +2,24 @@
2
2
 
3
3
  module RubyUI
4
4
  class TabsTrigger < Base
5
- def initialize(value:, **attrs)
5
+ def initialize(value:, as: :button, **attrs)
6
6
  @value = value
7
+ @as = as
7
8
  super(**attrs)
8
9
  end
9
10
 
10
11
  def view_template(&)
11
- button(**attrs, &)
12
+ if @as == :a
13
+ a(**attrs, &)
14
+ else
15
+ button(**attrs, &)
16
+ end
12
17
  end
13
18
 
14
19
  private
15
20
 
16
21
  def default_attrs
17
- {
18
- type: :button,
22
+ base = {
19
23
  data: {
20
24
  ruby_ui__tabs_target: "trigger",
21
25
  action: "click->ruby-ui--tabs#show",
@@ -29,6 +33,8 @@ module RubyUI
29
33
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
30
34
  ]
31
35
  }
36
+ base[:type] = :button unless @as == :a
37
+ base
32
38
  end
33
39
  end
34
40
  end
data/lib/ruby_ui.rb CHANGED
@@ -3,5 +3,5 @@
3
3
  require "date"
4
4
 
5
5
  module RubyUI
6
- VERSION = "1.3.0"
6
+ VERSION = "1.4.0"
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - George Kettle
@@ -453,7 +453,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
453
453
  - !ruby/object:Gem::Version
454
454
  version: '0'
455
455
  requirements: []
456
- rubygems_version: 4.0.10
456
+ rubygems_version: 3.6.9
457
457
  specification_version: 4
458
458
  summary: RubyUI is a UI Component Library for Ruby developers.
459
459
  test_files: []