ruby_ui 1.3.0 → 1.6.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 (69) 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 +12 -2
  6. data/lib/generators/ruby_ui/javascript_utils.rb +0 -13
  7. data/lib/ruby_ui/accordion/accordion_content.rb +4 -2
  8. data/lib/ruby_ui/accordion/accordion_controller.js +28 -6
  9. data/lib/ruby_ui/avatar/avatar_image.rb +5 -1
  10. data/lib/ruby_ui/bubble/bubble.rb +37 -0
  11. data/lib/ruby_ui/bubble/bubble_content.rb +23 -0
  12. data/lib/ruby_ui/bubble/bubble_docs.rb +167 -0
  13. data/lib/ruby_ui/bubble/bubble_group.rb +18 -0
  14. data/lib/ruby_ui/bubble/bubble_reactions.rb +38 -0
  15. data/lib/ruby_ui/combobox/combobox.rb +3 -1
  16. data/lib/ruby_ui/combobox/combobox_checkbox.rb +8 -1
  17. data/lib/ruby_ui/combobox/combobox_controller.js +3 -2
  18. data/lib/ruby_ui/combobox/combobox_trigger.rb +1 -4
  19. data/lib/ruby_ui/context_menu/context_menu_content.rb +5 -6
  20. data/lib/ruby_ui/context_menu/context_menu_controller.js +87 -75
  21. data/lib/ruby_ui/data_table/data_table_column_toggle.rb +8 -4
  22. data/lib/ruby_ui/data_table/data_table_column_visibility_controller.js +12 -2
  23. data/lib/ruby_ui/data_table/data_table_docs.rb +1 -1
  24. data/lib/ruby_ui/dialog/dialog_content.rb +7 -19
  25. data/lib/ruby_ui/dialog/dialog_controller.js +22 -10
  26. data/lib/ruby_ui/dropdown_menu/dropdown_menu.rb +0 -1
  27. data/lib/ruby_ui/dropdown_menu/dropdown_menu_controller.js +5 -1
  28. data/lib/ruby_ui/dropdown_menu/dropdown_menu_docs.rb +1 -1
  29. data/lib/ruby_ui/dropdown_menu/dropdown_menu_item.rb +10 -4
  30. data/lib/ruby_ui/empty/empty.rb +18 -0
  31. data/lib/ruby_ui/empty/empty_content.rb +18 -0
  32. data/lib/ruby_ui/empty/empty_description.rb +18 -0
  33. data/lib/ruby_ui/empty/empty_docs.rb +69 -0
  34. data/lib/ruby_ui/empty/empty_header.rb +18 -0
  35. data/lib/ruby_ui/empty/empty_media.rb +31 -0
  36. data/lib/ruby_ui/empty/empty_title.rb +18 -0
  37. data/lib/ruby_ui/form/form_docs.rb +89 -0
  38. data/lib/ruby_ui/hover_card/hover_card_content.rb +5 -6
  39. data/lib/ruby_ui/hover_card/hover_card_controller.js +113 -76
  40. data/lib/ruby_ui/input_otp/input_otp.rb +42 -0
  41. data/lib/ruby_ui/input_otp/input_otp_controller.js +128 -0
  42. data/lib/ruby_ui/input_otp/input_otp_docs.rb +213 -0
  43. data/lib/ruby_ui/input_otp/input_otp_group.rb +15 -0
  44. data/lib/ruby_ui/input_otp/input_otp_separator.rb +39 -0
  45. data/lib/ruby_ui/input_otp/input_otp_slot.rb +33 -0
  46. data/lib/ruby_ui/message/message.rb +23 -0
  47. data/lib/ruby_ui/message/message_avatar.rb +18 -0
  48. data/lib/ruby_ui/message/message_content.rb +18 -0
  49. data/lib/ruby_ui/message/message_docs.rb +173 -0
  50. data/lib/ruby_ui/message/message_footer.rb +18 -0
  51. data/lib/ruby_ui/message/message_group.rb +18 -0
  52. data/lib/ruby_ui/message/message_header.rb +18 -0
  53. data/lib/ruby_ui/message_scroller/message_scroller.rb +18 -0
  54. data/lib/ruby_ui/message_scroller/message_scroller_button.rb +56 -0
  55. data/lib/ruby_ui/message_scroller/message_scroller_content.rb +23 -0
  56. data/lib/ruby_ui/message_scroller/message_scroller_controller.js +335 -0
  57. data/lib/ruby_ui/message_scroller/message_scroller_docs.rb +208 -0
  58. data/lib/ruby_ui/message_scroller/message_scroller_item.rb +28 -0
  59. data/lib/ruby_ui/message_scroller/message_scroller_provider.rb +34 -0
  60. data/lib/ruby_ui/message_scroller/message_scroller_viewport.rb +22 -0
  61. data/lib/ruby_ui/native_select/native_select_icon.rb +1 -1
  62. data/lib/ruby_ui/popover/popover_content.rb +2 -1
  63. data/lib/ruby_ui/popover/popover_controller.js +95 -32
  64. data/lib/ruby_ui/table/table_docs.rb +2 -2
  65. data/lib/ruby_ui/tabs/tabs_docs.rb +1 -1
  66. data/lib/ruby_ui/tabs/tabs_trigger.rb +10 -4
  67. data/lib/ruby_ui/toast/toaster_controller.js +24 -8
  68. data/lib/ruby_ui.rb +1 -1
  69. metadata +35 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 41271e83392afeb5499d950f64ea1e5513c3694fac4521c7599f3ac1c281c979
4
- data.tar.gz: 5370ed6872556cf2848763b09d6edddecc2b5b822357c3af35e6bbfc1eb07ae0
3
+ metadata.gz: 2d65c0b25a3f006ce2a97107b84779fb313f55fe0937048f033a8a4477ee5bda
4
+ data.tar.gz: fccde45b64530b69c9c1b3330f2203da5d4921a2b89a48dbc25ae2d27f8d36fd
5
5
  SHA512:
6
- metadata.gz: e3694f2acd18f037160228f955f019c20696543b5047f87cb1a3108a272982b69c188bbbec174063add1881c29d3625f0fde34d7fe8d9dfebe9bb29621986831
7
- data.tar.gz: f0853192ca6871211535ac8e60e1db1dfae5adcba4fe60e4284f147bfe6296ce0ded60436764ff3829db5d20c874f3efcd7f68a56f47f411900f8c95f8d6dac3
6
+ metadata.gz: ed144e39bf1a8cdf62f5550a9fdf1426cbc609979dbc7049c33c9c0e6fac737adffad32bbe5f21dd21c6de8b974ffe5c61a9ebd24011001411edac378629b416
7
+ data.tar.gz: 158aa2d122d94feadfb41ebd67e6e2078cf3b8ef9675fb94f23d228e0d27fcfb3b206e46a6f8bf47c3f8ede71b8c68e32c18db7ff9a8e3188f5a000138142d4e
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
@@ -40,7 +40,7 @@ command:
40
40
 
41
41
  context_menu:
42
42
  js_packages:
43
- - "tippy.js"
43
+ - "@floating-ui/dom"
44
44
 
45
45
  date_picker:
46
46
  components:
@@ -64,7 +64,7 @@ dropdown_menu:
64
64
 
65
65
  hover_card:
66
66
  js_packages:
67
- - "tippy.js"
67
+ - "@floating-ui/dom"
68
68
 
69
69
  masked_input:
70
70
  components:
@@ -73,6 +73,16 @@ masked_input:
73
73
  js_packages:
74
74
  - "maska"
75
75
 
76
+ message:
77
+ components:
78
+ - "Avatar"
79
+ - "Bubble"
80
+
81
+ message_scroller:
82
+ components:
83
+ - "Message"
84
+ - "Bubble"
85
+
76
86
  pagination:
77
87
  components:
78
88
  - "Button"
@@ -25,8 +25,6 @@ module RubyUI
25
25
  pin_motion
26
26
  when "tw-animate-css"
27
27
  pin_tw_animate_css
28
- when "tippy.js"
29
- pin_tippy_js
30
28
  else
31
29
  run "bin/importmap pin #{package}"
32
30
  end
@@ -65,17 +63,6 @@ module RubyUI
65
63
  RUBY
66
64
  end
67
65
 
68
- def pin_tippy_js
69
- say <<~TEXT
70
- WARNING: Installing tippy.js from CDN because `bin/importmap pin tippy.js` doesn't download the correct file.
71
- TEXT
72
-
73
- inject_into_file rails_root.join("config/importmap.rb"), <<~RUBY
74
- pin "tippy.js", to: "https://cdn.jsdelivr.net/npm/tippy.js@6.3.7/+esm"
75
- pin "@popperjs/core", to: "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/+esm"\n
76
- RUBY
77
- end
78
-
79
66
  def rails_root = Rails.root
80
67
  end
81
68
  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,27 +65,49 @@ 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,
74
80
  easing: this.animationEasingValue,
75
81
  },
76
- );
82
+ )
83
+ .finished.then(() => {
84
+ if (content.dataset.state === "open") content.style.height = "auto";
85
+ })
86
+ .catch(() => {});
77
87
  }
78
88
 
79
89
  // Hide the accordion content with animation
80
90
  hideContent() {
91
+ const content = this.contentTarget;
92
+ content.dataset.state = "closed";
93
+
94
+ const currentHeight = content.getBoundingClientRect().height;
81
95
  animate(
82
- this.contentTarget,
83
- { height: 0 },
96
+ content,
97
+ { height: [`${currentHeight}px`, "0px"] },
84
98
  {
85
99
  duration: this.animationDurationValue,
86
100
  easing: this.animationEasingValue,
87
101
  },
88
- );
102
+ )
103
+ .finished.then(() => {
104
+ // After animation completes, truly hide the element so it is removed
105
+ // from layout and form focus — prevents trapped validation errors
106
+ if (content.dataset.state === "closed") {
107
+ content.setAttribute("hidden", "");
108
+ }
109
+ })
110
+ .catch(() => {});
89
111
  }
90
112
 
91
113
  // 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"
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class Bubble < Base
5
+ VARIANTS = {
6
+ default: "*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
7
+ secondary: "*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
8
+ muted: "*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
9
+ tinted: "*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
10
+ outline: "*:data-[slot=bubble-content]:bg-background *:data-[slot=bubble-content]:border-border [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
11
+ ghost: "*:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50 border-none",
12
+ destructive: "*:data-[slot=bubble-content]:bg-destructive/10 dark:*:data-[slot=bubble-content]:bg-destructive/20 *:data-[slot=bubble-content]:text-destructive [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30"
13
+ }
14
+
15
+ def initialize(variant: :default, align: :start, **attrs)
16
+ @variant = variant
17
+ @align = align
18
+ super(**attrs)
19
+ end
20
+
21
+ def view_template(&)
22
+ div(**attrs, &)
23
+ end
24
+
25
+ private
26
+
27
+ def default_attrs
28
+ {
29
+ data: {slot: "bubble", variant: @variant, align: @align},
30
+ class: [
31
+ "group/bubble relative flex w-fit min-w-0 flex-col gap-1 max-w-[80%] data-[align=end]:self-end data-[variant=ghost]:max-w-full",
32
+ VARIANTS[@variant]
33
+ ]
34
+ }
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class BubbleContent < Base
5
+ def initialize(as: :div, **attrs)
6
+ @as = as
7
+ super(**attrs)
8
+ end
9
+
10
+ def view_template(&)
11
+ send(@as, **attrs, &)
12
+ end
13
+
14
+ private
15
+
16
+ def default_attrs
17
+ {
18
+ data: {slot: "bubble-content"},
19
+ class: "w-fit max-w-full min-w-0 overflow-hidden wrap-break-word rounded-3xl border border-transparent px-3 py-2.5 text-sm leading-relaxed group-data-[align=end]/bubble:self-end [&:is(button,a)]:text-left [&:is(button,a)]:outline-none [&:is(button,a)]:transition-colors [&:is(button,a):focus-visible]:border-ring [&:is(button,a):focus-visible]:ring-3 [&:is(button,a):focus-visible]:ring-ring/30"
20
+ }
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Views::Docs::Bubble < Views::Base
4
+ def view_template
5
+ component = "Bubble"
6
+
7
+ div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
8
+ render Docs::Header.new(title: "Bubble", description: "A chat bubble surface for displaying conversational content, with variants, alignment, grouping, and reactions.")
9
+
10
+ Heading(level: 2) { "Usage" }
11
+
12
+ render Docs::VisualCodeExample.new(title: "Default", context: self) do
13
+ <<~RUBY
14
+ Bubble(align: :end) do
15
+ BubbleContent { "Hey there! what's up?" }
16
+ end
17
+ RUBY
18
+ end
19
+
20
+ render Docs::VisualCodeExample.new(title: "Conversation", context: self) do
21
+ <<~RUBY
22
+ div(class: "flex flex-col gap-8") do
23
+ Bubble(align: :end) do
24
+ BubbleContent { "Hey there! what's up?" }
25
+ end
26
+ BubbleGroup do
27
+ Bubble(variant: :muted) do
28
+ BubbleContent { "Hey! Want to see chat bubbles?" }
29
+ end
30
+ Bubble(variant: :muted) do
31
+ BubbleContent { "I can group messages, switch sides, and keep the whole thread easy to scan." }
32
+ BubbleReactions(role: "img", aria_label: "Reaction: thumbs up") do
33
+ span { "👍" }
34
+ end
35
+ end
36
+ end
37
+ Bubble(align: :end) do
38
+ BubbleContent { "Sure. Hit me with your best demo." }
39
+ end
40
+ end
41
+ RUBY
42
+ end
43
+
44
+ Heading(level: 2) { "Variants" }
45
+
46
+ render Docs::VisualCodeExample.new(title: "Variants", context: self) do
47
+ <<~RUBY
48
+ div(class: "flex flex-col gap-4 w-full") do
49
+ Bubble(variant: :default) { BubbleContent { "Default" } }
50
+ Bubble(variant: :secondary) { BubbleContent { "Secondary" } }
51
+ Bubble(variant: :muted) { BubbleContent { "Muted" } }
52
+ Bubble(variant: :tinted) { BubbleContent { "Tinted" } }
53
+ Bubble(variant: :outline) { BubbleContent { "Outline" } }
54
+ Bubble(variant: :ghost) { BubbleContent { "Ghost — unframed, full width for assistant text or markdown." } }
55
+ Bubble(variant: :destructive) { BubbleContent { "Destructive — something went wrong." } }
56
+ end
57
+ RUBY
58
+ end
59
+
60
+ Heading(level: 2) { "Alignment" }
61
+
62
+ render Docs::VisualCodeExample.new(title: "Start and end", context: self) do
63
+ <<~RUBY
64
+ div(class: "flex flex-col gap-4 w-full") do
65
+ Bubble(align: :start, variant: :muted) do
66
+ BubbleContent { "Aligned to the start (receiver)." }
67
+ end
68
+ Bubble(align: :end) do
69
+ BubbleContent { "Aligned to the end (sender)." }
70
+ end
71
+ end
72
+ RUBY
73
+ end
74
+
75
+ Heading(level: 2) { "Reactions" }
76
+
77
+ render Docs::VisualCodeExample.new(title: "Reactions", context: self) do
78
+ <<~RUBY
79
+ div(class: "flex flex-col gap-10 w-full py-6") do
80
+ Bubble(variant: :muted) do
81
+ BubbleContent { "Reactions anchor to the bubble edge." }
82
+ BubbleReactions(role: "img", aria_label: "Reactions: thumbs up, fire, eyes, and 2 more") do
83
+ span { "👍" }
84
+ span { "🔥" }
85
+ span { "👀" }
86
+ span { "+2" }
87
+ end
88
+ end
89
+ Bubble(align: :end) do
90
+ BubbleContent { "Place them on top and to the start too." }
91
+ BubbleReactions(side: :top, align: :start, role: "img", aria_label: "Reaction: heart") do
92
+ span { "❤️" }
93
+ end
94
+ end
95
+ end
96
+ RUBY
97
+ end
98
+
99
+ Heading(level: 2) { "Group" }
100
+
101
+ render Docs::VisualCodeExample.new(title: "Bubble group", context: self) do
102
+ <<~RUBY
103
+ BubbleGroup do
104
+ Bubble(variant: :muted) { BubbleContent { "First message in the group." } }
105
+ Bubble(variant: :muted) { BubbleContent { "Second one, tighter spacing." } }
106
+ Bubble(variant: :muted) { BubbleContent { "Third, all stacked together." } }
107
+ end
108
+ RUBY
109
+ end
110
+
111
+ Heading(level: 2) { "Link or button bubble" }
112
+
113
+ render Docs::VisualCodeExample.new(title: "Interactive content", context: self) do
114
+ <<~RUBY
115
+ div(class: "flex flex-col gap-4 w-full") do
116
+ Bubble(align: :end) do
117
+ BubbleContent(as: :a, href: "#") { "Tap to open the link →" }
118
+ end
119
+ Bubble(variant: :outline) do
120
+ BubbleContent(as: :button, type: "button") { "Retry sending" }
121
+ end
122
+ end
123
+ RUBY
124
+ end
125
+
126
+ Heading(level: 2) { "With Tooltip" }
127
+
128
+ render Docs::VisualCodeExample.new(title: "Reveal metadata on hover", context: self) do
129
+ <<~RUBY
130
+ Tooltip do
131
+ TooltipTrigger(class: "w-fit") do
132
+ Bubble(variant: :muted, class: "max-w-none") do
133
+ BubbleContent { "Read 9:41 AM" }
134
+ end
135
+ end
136
+ TooltipContent do
137
+ Text { "Delivered and read" }
138
+ end
139
+ end
140
+ RUBY
141
+ end
142
+
143
+ Heading(level: 2) { "With Popover" }
144
+
145
+ render Docs::VisualCodeExample.new(title: "Surface details on demand", context: self) do
146
+ <<~RUBY
147
+ Popover do
148
+ PopoverTrigger do
149
+ Bubble(variant: :destructive, class: "max-w-none") do
150
+ BubbleContent { "Message failed to send" }
151
+ end
152
+ end
153
+ PopoverContent(class: "w-64") do
154
+ Text(weight: :semibold) { "Delivery error" }
155
+ Text(size: :sm, class: "text-muted-foreground") { "The recipient's inbox is full. Try again later." }
156
+ end
157
+ end
158
+ RUBY
159
+ end
160
+
161
+ render Components::ComponentSetup::Tabs.new(component_name: component)
162
+
163
+ # components
164
+ render Docs::ComponentsTable.new(component_files(component))
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class BubbleGroup < Base
5
+ def view_template(&)
6
+ div(**attrs, &)
7
+ end
8
+
9
+ private
10
+
11
+ def default_attrs
12
+ {
13
+ data: {slot: "bubble-group"},
14
+ class: "flex min-w-0 flex-col gap-2"
15
+ }
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class BubbleReactions < Base
5
+ SIDES = {
6
+ top: "top-0 -translate-y-3/4",
7
+ bottom: "bottom-0 translate-y-3/4"
8
+ }
9
+
10
+ ALIGNS = {
11
+ start: "left-3",
12
+ end: "right-3"
13
+ }
14
+
15
+ def initialize(side: :bottom, align: :end, **attrs)
16
+ @side = side
17
+ @align = align
18
+ super(**attrs)
19
+ end
20
+
21
+ def view_template(&)
22
+ div(**attrs, &)
23
+ end
24
+
25
+ private
26
+
27
+ def default_attrs
28
+ {
29
+ data: {slot: "bubble-reactions", side: @side, align: @align},
30
+ class: [
31
+ "absolute z-10 flex w-fit items-center justify-center rounded-full ring-3 ring-card bg-muted shrink-0 gap-1 px-1.5 py-0.5 has-[button]:p-0 text-sm",
32
+ SIDES[@side],
33
+ ALIGNS[@align]
34
+ ]
35
+ }
36
+ end
37
+ end
38
+ end
@@ -2,8 +2,9 @@
2
2
 
3
3
  module RubyUI
4
4
  class Combobox < Base
5
- def initialize(term: nil, **)
5
+ def initialize(term: nil, placement: "bottom-start", **)
6
6
  @term = term
7
+ @placement = placement
7
8
  super(**)
8
9
  end
9
10
 
@@ -19,6 +20,7 @@ module RubyUI
19
20
  data: {
20
21
  controller: "ruby-ui--combobox",
21
22
  ruby_ui__combobox_term_value: @term,
23
+ ruby_ui__combobox_placement_value: @placement,
22
24
  action: "turbo:morph@window->ruby-ui--combobox#updateTriggerContent"
23
25
  }
24
26
  }
@@ -19,7 +19,14 @@ module RubyUI
19
19
  ],
20
20
  data: {
21
21
  ruby_ui__combobox_target: "input",
22
- action: "ruby-ui--combobox#inputChanged"
22
+ ruby_ui__checkbox_group_target: "checkbox",
23
+ ruby_ui__form_field_target: "input",
24
+ action: %w[
25
+ ruby-ui--combobox#inputChanged
26
+ change->ruby-ui--checkbox-group#onChange
27
+ change->ruby-ui--form-field#onInput
28
+ invalid->ruby-ui--form-field#onInvalid
29
+ ]
23
30
  }
24
31
  }
25
32
  end
@@ -5,7 +5,8 @@ import { computePosition, autoUpdate, offset, flip } from "@floating-ui/dom";
5
5
  export default class extends Controller {
6
6
  static values = {
7
7
  term: String,
8
- minPopoverWidth: { type: Number, default: 240 }
8
+ minPopoverWidth: { type: Number, default: 240 },
9
+ placement: { type: String, default: "bottom-start" }
9
10
  }
10
11
 
11
12
  static targets = [
@@ -175,7 +176,7 @@ export default class extends Controller {
175
176
  updatePopoverPosition() {
176
177
  this.cleanup = autoUpdate(this.triggerTarget, this.popoverTarget, () => {
177
178
  computePosition(this.triggerTarget, this.popoverTarget, {
178
- placement: 'bottom-start',
179
+ placement: this.placementValue,
179
180
  middleware: [offset(4), flip()],
180
181
  }).then(({ x, y }) => {
181
182
  Object.assign(this.popoverTarget.style, {