ruby_ui 1.2.0 → 1.3.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/ruby_ui/dependencies.yml +32 -10
  3. data/lib/generators/ruby_ui/install/templates/tailwind.css.erb +1 -1
  4. data/lib/generators/ruby_ui/javascript_utils.rb +24 -7
  5. data/lib/ruby_ui/avatar/avatar.rb +3 -0
  6. data/lib/ruby_ui/avatar/avatar_controller.js +33 -0
  7. data/lib/ruby_ui/avatar/avatar_fallback.rb +3 -0
  8. data/lib/ruby_ui/avatar/avatar_image.rb +4 -0
  9. data/lib/ruby_ui/base.rb +6 -0
  10. data/lib/ruby_ui/calendar/calendar.rb +3 -1
  11. data/lib/ruby_ui/calendar/calendar_controller.js +66 -7
  12. data/lib/ruby_ui/calendar/calendar_days.rb +20 -0
  13. data/lib/ruby_ui/calendar/calendar_docs.rb +9 -0
  14. data/lib/ruby_ui/combobox/combobox.rb +1 -7
  15. data/lib/ruby_ui/combobox/combobox_checkbox.rb +7 -1
  16. data/lib/ruby_ui/combobox/combobox_controller.js +56 -244
  17. data/lib/ruby_ui/combobox/combobox_item.rb +7 -5
  18. data/lib/ruby_ui/combobox/combobox_list_group.rb +1 -1
  19. data/lib/ruby_ui/combobox/combobox_popover.rb +5 -0
  20. data/lib/ruby_ui/combobox/combobox_radio.rb +8 -1
  21. data/lib/ruby_ui/combobox/combobox_toggle_all_checkbox.rb +7 -1
  22. data/lib/ruby_ui/combobox/combobox_trigger.rb +19 -19
  23. data/lib/ruby_ui/command/command_controller.js +10 -19
  24. data/lib/ruby_ui/command/command_dialog.rb +4 -1
  25. data/lib/ruby_ui/command/command_dialog_content.rb +2 -2
  26. data/lib/ruby_ui/command/command_dialog_controller.js +34 -0
  27. data/lib/ruby_ui/command/command_dialog_trigger.rb +2 -2
  28. data/lib/ruby_ui/date_picker/date_picker.rb +85 -0
  29. data/lib/ruby_ui/date_picker/date_picker_docs.rb +23 -0
  30. data/lib/ruby_ui/masked_input/masked_input.rb +1 -11
  31. data/lib/ruby_ui/masked_input/masked_input_controller.js +0 -13
  32. data/lib/ruby_ui/select/select_value.rb +2 -1
  33. data/lib/ruby_ui/sheet/sheet.rb +9 -1
  34. data/lib/ruby_ui/sheet/sheet_controller.js +6 -0
  35. data/lib/ruby_ui/theme_toggle/theme_toggle.rb +14 -2
  36. data/lib/ruby_ui/theme_toggle/theme_toggle_controller.js +27 -19
  37. data/lib/ruby_ui/theme_toggle/theme_toggle_docs.rb +12 -42
  38. data/lib/ruby_ui/toast/toast.rb +18 -0
  39. data/lib/ruby_ui/toast/toast_action.rb +27 -0
  40. data/lib/ruby_ui/toast/toast_cancel.rb +27 -0
  41. data/lib/ruby_ui/toast/toast_close.rb +40 -0
  42. data/lib/ruby_ui/toast/toast_controller.js +151 -0
  43. data/lib/ruby_ui/toast/toast_description.rb +18 -0
  44. data/lib/ruby_ui/toast/toast_docs.rb +12 -0
  45. data/lib/ruby_ui/toast/toast_icon.rb +65 -0
  46. data/lib/ruby_ui/toast/toast_item.rb +72 -0
  47. data/lib/ruby_ui/toast/toast_region.rb +124 -0
  48. data/lib/ruby_ui/toast/toast_title.rb +18 -0
  49. data/lib/ruby_ui/toast/toaster_controller.js +306 -0
  50. data/lib/ruby_ui/toggle/toggle.rb +101 -0
  51. data/lib/ruby_ui/toggle/toggle_controller.js +33 -0
  52. data/lib/ruby_ui/toggle_group/toggle_group.rb +119 -0
  53. data/lib/ruby_ui/toggle_group/toggle_group_controller.js +126 -0
  54. data/lib/ruby_ui/toggle_group/toggle_group_item.rb +67 -0
  55. data/lib/ruby_ui/tooltip/tooltip_content.rb +12 -5
  56. data/lib/ruby_ui/tooltip/tooltip_controller.js +58 -22
  57. data/lib/ruby_ui/tooltip/tooltip_docs.rb +13 -0
  58. data/lib/ruby_ui/tooltip/tooltip_trigger.rb +10 -3
  59. data/lib/ruby_ui.rb +3 -1
  60. metadata +30 -14
  61. data/lib/ruby_ui/theme_toggle/set_dark_mode.rb +0 -16
  62. data/lib/ruby_ui/theme_toggle/set_light_mode.rb +0 -16
@@ -8,7 +8,7 @@ module RubyUI
8
8
  ].freeze
9
9
 
10
10
  def initialize(keybindings: DEFAULT_KEYBINDINGS, **attrs)
11
- @keybindings = keybindings.map { |kb| "#{kb}->ruby-ui--command#open" }
11
+ @keybindings = keybindings.map { |kb| "#{kb}->ruby-ui--command-dialog#open" }
12
12
  super(**attrs)
13
13
  end
14
14
 
@@ -21,7 +21,7 @@ module RubyUI
21
21
  def default_attrs
22
22
  {
23
23
  data: {
24
- action: ["click->ruby-ui--command#open", @keybindings.join(" ")]
24
+ action: ["click->ruby-ui--command-dialog#open", @keybindings.join(" ")]
25
25
  }
26
26
  }
27
27
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module RubyUI
6
+ class DatePicker < Base
7
+ def initialize(
8
+ id: nil,
9
+ name: nil,
10
+ label: "Select a date",
11
+ value: nil,
12
+ placeholder: "Select a date",
13
+ selected_date: value,
14
+ date_format: "yyyy-MM-dd",
15
+ popover_options: {},
16
+ input_attrs: {},
17
+ calendar_attrs: {},
18
+ trigger_attrs: {},
19
+ content_attrs: {},
20
+ **attrs
21
+ )
22
+ @id = id || "date-picker-#{SecureRandom.hex(4)}"
23
+ @name = name
24
+ @label = label
25
+ @value = value || selected_date&.to_s
26
+ @placeholder = placeholder
27
+ @selected_date = selected_date
28
+ @date_format = date_format
29
+ @popover_options = {trigger: "click"}.merge(popover_options)
30
+ @input_attrs = input_attrs
31
+ @calendar_attrs = calendar_attrs
32
+ @trigger_attrs = trigger_attrs
33
+ @content_attrs = content_attrs
34
+ super(**attrs)
35
+ end
36
+
37
+ def view_template
38
+ div(**attrs) do
39
+ RubyUI.Popover(options: @popover_options) do
40
+ RubyUI.PopoverTrigger(**trigger_attrs) do
41
+ div(class: "grid w-full max-w-sm items-center gap-1.5") do
42
+ label(for: @id) { @label } if @label
43
+ RubyUI.Input(**input_attrs)
44
+ end
45
+ end
46
+ RubyUI.PopoverContent(**content_attrs) do
47
+ RubyUI.Calendar(input_id: "##{@id}", selected_date: @selected_date, date_format: @date_format, **calendar_attrs)
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def default_attrs
56
+ {
57
+ class: "space-y-4 w-[260px]"
58
+ }
59
+ end
60
+
61
+ def trigger_attrs
62
+ mix({class: "w-full"}, @trigger_attrs)
63
+ end
64
+
65
+ def input_attrs
66
+ mix({
67
+ type: "string",
68
+ placeholder: @placeholder,
69
+ id: @id,
70
+ name: @name,
71
+ value: @value,
72
+ data_controller: "ruby-ui--calendar-input",
73
+ class: "rounded-md border shadow"
74
+ }.compact, @input_attrs)
75
+ end
76
+
77
+ def calendar_attrs
78
+ mix({}, @calendar_attrs)
79
+ end
80
+
81
+ def content_attrs
82
+ mix({}, @content_attrs)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Views::Docs::DatePicker < Views::Base
4
+ def view_template
5
+ component = "DatePicker"
6
+
7
+ div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
8
+ render Docs::Header.new(title: "Date Picker", description: "A date picker component with input.")
9
+
10
+ Heading(level: 2) { "Usage" }
11
+
12
+ render Docs::VisualCodeExample.new(title: "Single Date", context: self) do
13
+ <<~RUBY
14
+ DatePicker(id: "date")
15
+ RUBY
16
+ end
17
+
18
+ render Components::ComponentSetup::Tabs.new(component_name: component)
19
+
20
+ render Docs::ComponentsTable.new(component_files(component))
21
+ end
22
+ end
23
+ end
@@ -2,18 +2,8 @@
2
2
 
3
3
  module RubyUI
4
4
  class MaskedInput < Base
5
- def initialize(save_unmasked: false, **attrs)
6
- @save_unmasked = save_unmasked
7
- super(**attrs)
8
- end
9
-
10
5
  def view_template
11
- if @save_unmasked
12
- Input(type: "text", **attrs.merge(name: "#{attrs[:name]}-masked"))
13
- input(type: "hidden", name: attrs[:name], value: attrs[:value])
14
- else
15
- Input(type: "text", **attrs)
16
- end
6
+ Input(type: "text", **attrs)
17
7
  end
18
8
 
19
9
  private
@@ -5,18 +5,5 @@ import { MaskInput } from "maska";
5
5
  export default class extends Controller {
6
6
  connect() {
7
7
  new MaskInput(this.element)
8
- this.#boundSync = this.#sync.bind(this);
9
- this.element.addEventListener("maska", this.#boundSync);
10
- }
11
-
12
- disconnect() {
13
- this.element.removeEventListener("maska", this.#boundSync);
14
- }
15
-
16
- #boundSync = null;
17
-
18
- #sync(event) {
19
- const hidden = this.element.nextElementSibling;
20
- if (hidden?.type === "hidden") hidden.value = event.detail.unmasked;
21
8
  }
22
9
  }
@@ -9,7 +9,8 @@ module RubyUI
9
9
 
10
10
  def view_template(&block)
11
11
  span(**attrs) do
12
- block ? block.call : @placeholder
12
+ value = block ? block.call : @placeholder
13
+ value || @placeholder
13
14
  end
14
15
  end
15
16
 
@@ -2,6 +2,11 @@
2
2
 
3
3
  module RubyUI
4
4
  class Sheet < Base
5
+ def initialize(open: false, **attrs)
6
+ @open = open
7
+ super(**attrs)
8
+ end
9
+
5
10
  def view_template(&)
6
11
  div(**attrs, &)
7
12
  end
@@ -10,7 +15,10 @@ module RubyUI
10
15
 
11
16
  def default_attrs
12
17
  {
13
- data: {controller: "ruby-ui--sheet"}
18
+ data: {
19
+ controller: "ruby-ui--sheet",
20
+ ruby_ui__sheet_open_value: @open.to_s
21
+ }
14
22
  }
15
23
  end
16
24
  end
@@ -3,6 +3,12 @@ import { Controller } from "@hotwired/stimulus"
3
3
  export default class extends Controller {
4
4
  static targets = ["content"]
5
5
 
6
+ static values = { open: false }
7
+
8
+ connect() {
9
+ if (this.openValue) this.open()
10
+ }
11
+
6
12
  open() {
7
13
  document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML)
8
14
  }
@@ -2,8 +2,20 @@
2
2
 
3
3
  module RubyUI
4
4
  class ThemeToggle < Base
5
- def view_template(&)
6
- div(**attrs, &)
5
+ def view_template(&block)
6
+ RubyUI.Toggle(
7
+ variant: :default,
8
+ size: :default,
9
+ aria: {label: "Toggle theme"},
10
+ wrapper: {
11
+ data: {
12
+ controller: "ruby-ui--theme-toggle",
13
+ action: "ruby-ui--toggle:change->ruby-ui--theme-toggle#apply"
14
+ }
15
+ },
16
+ **attrs,
17
+ &block
18
+ )
7
19
  end
8
20
  end
9
21
  end
@@ -1,30 +1,38 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
+ // Connects to data-controller="ruby-ui--theme-toggle"
4
+ // Sits on the same wrapper as ruby-ui--toggle. Listens for the toggle's
5
+ // ruby-ui--toggle:change event. pressed = dark mode.
3
6
  export default class extends Controller {
4
- initialize() {
5
- this.setTheme()
7
+ connect() {
8
+ this.applyTheme(this.currentTheme())
6
9
  }
7
10
 
8
- setTheme() {
9
- // On page load or when changing themes, best to add inline in `head` to avoid FOUC
10
- if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
11
- document.documentElement.classList.add('dark')
12
- document.documentElement.classList.remove('light')
13
- } else {
14
- document.documentElement.classList.remove('dark')
15
- document.documentElement.classList.add('light')
16
- }
11
+ apply(event) {
12
+ const pressed = event.detail?.pressed
13
+ const theme = pressed ? "dark" : "light"
14
+ localStorage.theme = theme
15
+ this.applyTheme(theme)
17
16
  }
18
17
 
19
- setLightTheme() {
20
- // Whenever the user explicitly chooses light mode
21
- localStorage.theme = 'light'
22
- this.setTheme()
18
+ currentTheme() {
19
+ if (localStorage.theme === "dark") return "dark"
20
+ if (localStorage.theme === "light") return "light"
21
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
23
22
  }
24
23
 
25
- setDarkTheme() {
26
- // Whenever the user explicitly chooses dark mode
27
- localStorage.theme = 'dark'
28
- this.setTheme()
24
+ applyTheme(theme) {
25
+ const html = document.documentElement
26
+ if (theme === "dark") {
27
+ html.classList.add("dark")
28
+ html.classList.remove("light")
29
+ } else {
30
+ html.classList.add("light")
31
+ html.classList.remove("dark")
32
+ }
33
+ // Flip the sibling Toggle controller's pressed value; it will propagate
34
+ // aria-pressed / data-state to the button target.
35
+ const dark = theme === "dark"
36
+ this.element.setAttribute("data-ruby-ui--toggle-pressed-value", dark ? "true" : "false")
29
37
  }
30
38
  }
@@ -11,39 +11,17 @@ class Views::Docs::ThemeToggle < Views::Base
11
11
 
12
12
  render Docs::VisualCodeExample.new(title: "With icon", context: self) do
13
13
  <<~RUBY
14
- ThemeToggle do |toggle|
15
- SetLightMode do
16
- Button(variant: :ghost, icon: true) do
17
- svg(
18
- xmlns: "http://www.w3.org/2000/svg",
19
- viewbox: "0 0 24 24",
20
- fill: "currentColor",
21
- class: "w-4 h-4"
22
- ) do |s|
23
- s.path(
24
- d:
25
- "M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z"
26
- )
27
- end
28
- end
29
- end
30
-
31
- SetDarkMode do
32
- Button(variant: :ghost, icon: true) do
33
- svg(
34
- xmlns: "http://www.w3.org/2000/svg",
35
- viewbox: "0 0 24 24",
36
- fill: "currentColor",
37
- class: "w-4 h-4"
38
- ) do |s|
39
- s.path(
40
- fill_rule: "evenodd",
41
- d:
42
- "M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.701-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z",
43
- clip_rule: "evenodd"
44
- )
45
- end
46
- end
14
+ ThemeToggle do
15
+ svg(
16
+ xmlns: "http://www.w3.org/2000/svg",
17
+ viewbox: "0 0 24 24",
18
+ fill: "currentColor",
19
+ class: "w-4 h-4"
20
+ ) do |s|
21
+ s.path(
22
+ d:
23
+ "M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z"
24
+ )
47
25
  end
48
26
  end
49
27
  RUBY
@@ -51,15 +29,7 @@ class Views::Docs::ThemeToggle < Views::Base
51
29
 
52
30
  render Docs::VisualCodeExample.new(title: "With text", context: self) do
53
31
  <<~RUBY
54
- ThemeToggle do |toggle|
55
- SetLightMode do
56
- Button(variant: :primary) { "Light" }
57
- end
58
-
59
- SetDarkMode do
60
- Button(variant: :primary) { "Dark" }
61
- end
62
- end
32
+ ThemeToggle { "Toggle Theme" }
63
33
  RUBY
64
34
  end
65
35
 
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ module Toast
5
+ FLASH_VARIANTS = {
6
+ "notice" => :info,
7
+ "alert" => :warning,
8
+ "success" => :success,
9
+ "error" => :error,
10
+ "warning" => :warning,
11
+ "info" => :info
12
+ }.freeze
13
+
14
+ def self.flash_variant(key)
15
+ FLASH_VARIANTS[key.to_s] || :default
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class ToastAction < Base
5
+ def initialize(label:, on: nil, **attrs)
6
+ @label = label
7
+ @on = on
8
+ super(**attrs)
9
+ end
10
+
11
+ def view_template
12
+ button(**attrs) { @label }
13
+ end
14
+
15
+ private
16
+
17
+ def default_attrs
18
+ data = {slot: "action"}
19
+ data[:action] = @on if @on
20
+ {
21
+ type: "button",
22
+ data: data,
23
+ class: "inline-flex h-6 shrink-0 cursor-pointer items-center justify-center rounded px-2 text-xs font-medium bg-foreground text-background border-0 ml-auto hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-ring transition-opacity disabled:pointer-events-none disabled:opacity-50"
24
+ }
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class ToastCancel < Base
5
+ def initialize(label:, **attrs)
6
+ @label = label
7
+ super(**attrs)
8
+ end
9
+
10
+ def view_template
11
+ button(**attrs) { @label }
12
+ end
13
+
14
+ private
15
+
16
+ def default_attrs
17
+ {
18
+ type: "button",
19
+ data: {
20
+ slot: "cancel",
21
+ action: "click->ruby-ui--toast#dismiss"
22
+ },
23
+ class: "inline-flex h-6 shrink-0 cursor-pointer items-center justify-center rounded px-2 text-xs font-medium bg-foreground/10 text-foreground border-0 ml-auto hover:bg-foreground/15 focus:outline-none focus:ring-2 focus:ring-ring transition-colors"
24
+ }
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class ToastClose < Base
5
+ def view_template
6
+ button(**attrs) do
7
+ svg(
8
+ xmlns: "http://www.w3.org/2000/svg",
9
+ width: "14",
10
+ height: "14",
11
+ viewbox: "0 0 24 24",
12
+ fill: "none",
13
+ stroke: "currentColor",
14
+ stroke_width: "2",
15
+ stroke_linecap: "round",
16
+ stroke_linejoin: "round",
17
+ class: "size-3.5"
18
+ ) do |s|
19
+ s.path(d: "M18 6 6 18")
20
+ s.path(d: "m6 6 12 12")
21
+ end
22
+ span(class: "sr-only") { "Close" }
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def default_attrs
29
+ {
30
+ type: "button",
31
+ aria_label: "Close toast",
32
+ data: {
33
+ slot: "close",
34
+ action: "click->ruby-ui--toast#dismiss"
35
+ },
36
+ class: "absolute right-2 top-2 size-6 cursor-pointer rounded-md text-foreground/60 p-0 flex items-center justify-center transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
37
+ }
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,151 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const SWIPE_THRESHOLD = 45
4
+ const TIME_BEFORE_UNMOUNT = 200
5
+
6
+ // Connects to data-controller="ruby-ui--toast"
7
+ export default class extends Controller {
8
+ static values = {
9
+ duration: { type: Number, default: 4000 },
10
+ dismissible: { type: Boolean, default: true },
11
+ invert: { type: Boolean, default: false },
12
+ onDismiss: String,
13
+ onAutoClose: String,
14
+ }
15
+
16
+ connect() {
17
+ this._timer = null
18
+ this._startedAt = 0
19
+ this._remaining = this.durationValue
20
+ this._paused = false
21
+ this._swipe = { active: false, x: 0, y: 0, startedAt: 0 }
22
+
23
+ this._onPointerDown = this._onPointerDown.bind(this)
24
+ this._onPointerMove = this._onPointerMove.bind(this)
25
+ this._onPointerUp = this._onPointerUp.bind(this)
26
+ this._onPointerEnter = () => this._pause()
27
+ this._onPointerLeave = () => { if (!this._swipe.active) this._resume() }
28
+ this._onKeyDown = this._onKeyDown.bind(this)
29
+ this._onForceDismiss = (e) => { e.stopPropagation(); this._close() }
30
+ this._onRestart = () => this._restart()
31
+ this._onRegionPause = () => this._pause()
32
+ this._onRegionResume = () => this._resume()
33
+
34
+ this.element.addEventListener("pointerdown", this._onPointerDown)
35
+ this.element.addEventListener("pointerenter", this._onPointerEnter)
36
+ this.element.addEventListener("pointerleave", this._onPointerLeave)
37
+ this.element.addEventListener("keydown", this._onKeyDown)
38
+ this.element.addEventListener("ruby-ui:toast:force-dismiss", this._onForceDismiss)
39
+ this.element.addEventListener("ruby-ui:toast:restart", this._onRestart)
40
+ document.addEventListener("ruby-ui:toast:pause", this._onRegionPause)
41
+ document.addEventListener("ruby-ui:toast:resume", this._onRegionResume)
42
+
43
+ requestAnimationFrame(() => {
44
+ this.element.dataset.state = "open"
45
+ this._start()
46
+ })
47
+ }
48
+
49
+ disconnect() {
50
+ this._clearTimer()
51
+ this.element.removeEventListener("pointerdown", this._onPointerDown)
52
+ this.element.removeEventListener("pointerenter", this._onPointerEnter)
53
+ this.element.removeEventListener("pointerleave", this._onPointerLeave)
54
+ this.element.removeEventListener("keydown", this._onKeyDown)
55
+ this.element.removeEventListener("ruby-ui:toast:force-dismiss", this._onForceDismiss)
56
+ this.element.removeEventListener("ruby-ui:toast:restart", this._onRestart)
57
+ document.removeEventListener("ruby-ui:toast:pause", this._onRegionPause)
58
+ document.removeEventListener("ruby-ui:toast:resume", this._onRegionResume)
59
+ }
60
+
61
+ dismiss(e) {
62
+ e?.preventDefault()
63
+ if (!this.dismissibleValue) return
64
+ this._close("dismiss")
65
+ }
66
+
67
+ _close(reason) {
68
+ if (this.element.dataset.state === "closing") return
69
+ this.element.dataset.state = "closing"
70
+ this.element.dispatchEvent(new CustomEvent(reason === "auto" ? "ruby-ui:toast:auto-close" : "ruby-ui:toast:dismiss", { bubbles: true, detail: { id: this.element.id } }))
71
+ setTimeout(() => this.element.remove(), TIME_BEFORE_UNMOUNT)
72
+ }
73
+
74
+ _start() {
75
+ if (!Number.isFinite(this.durationValue) || this.durationValue <= 0) return
76
+ this._startedAt = performance.now()
77
+ this._remaining = this.durationValue
78
+ this._timer = setTimeout(() => this._close("auto"), this._remaining)
79
+ }
80
+
81
+ _restart() {
82
+ this._clearTimer()
83
+ this._start()
84
+ }
85
+
86
+ _pause() {
87
+ if (this._paused || !this._timer) return
88
+ this._paused = true
89
+ clearTimeout(this._timer)
90
+ this._timer = null
91
+ this._remaining -= performance.now() - this._startedAt
92
+ }
93
+
94
+ _resume() {
95
+ if (!this._paused) return
96
+ this._paused = false
97
+ if (this._remaining <= 0) return this._close("auto")
98
+ this._startedAt = performance.now()
99
+ this._timer = setTimeout(() => this._close("auto"), this._remaining)
100
+ }
101
+
102
+ _clearTimer() {
103
+ if (this._timer) clearTimeout(this._timer)
104
+ this._timer = null
105
+ }
106
+
107
+ _onKeyDown(e) {
108
+ if (e.key === "Escape" && this.dismissibleValue) this.dismiss(e)
109
+ }
110
+
111
+ _onPointerDown(e) {
112
+ if (!this.dismissibleValue) return
113
+ if (e.target.closest("button")) return
114
+ try { this.element.setPointerCapture(e.pointerId) } catch {}
115
+ this._swipe = { active: true, x: e.clientX, y: e.clientY, startedAt: performance.now(), pointerId: e.pointerId }
116
+ this.element.dataset.swipe = "start"
117
+ this.element.addEventListener("pointermove", this._onPointerMove)
118
+ this.element.addEventListener("pointerup", this._onPointerUp)
119
+ this.element.addEventListener("pointercancel", this._onPointerUp)
120
+ }
121
+
122
+ _onPointerMove(e) {
123
+ const dx = e.clientX - this._swipe.x
124
+ const dy = e.clientY - this._swipe.y
125
+ this.element.dataset.swipe = "move"
126
+ this.element.style.transform = `translate(${dx}px, ${dy}px)`
127
+ }
128
+
129
+ _onPointerUp(e) {
130
+ const dx = e.clientX - this._swipe.x
131
+ const dy = e.clientY - this._swipe.y
132
+ const dist = Math.hypot(dx, dy)
133
+ const dt = performance.now() - this._swipe.startedAt
134
+ const velocity = dist / Math.max(dt, 1)
135
+ this.element.removeEventListener("pointermove", this._onPointerMove)
136
+ this.element.removeEventListener("pointerup", this._onPointerUp)
137
+ this.element.removeEventListener("pointercancel", this._onPointerUp)
138
+ this._swipe.active = false
139
+ if (dist > SWIPE_THRESHOLD || velocity > 0.5) {
140
+ this.element.style.setProperty("--swipe-end-x", `${Math.sign(dx) * 500}px`)
141
+ this.element.style.setProperty("--swipe-end-y", `${Math.sign(dy) * 500}px`)
142
+ this.element.dataset.swipe = "end"
143
+ this.element.style.transform = ""
144
+ this._close("dismiss")
145
+ } else {
146
+ this.element.dataset.swipe = "cancel"
147
+ this.element.style.transform = ""
148
+ this._resume()
149
+ }
150
+ }
151
+ }
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class ToastDescription < Base
5
+ def view_template(&)
6
+ div(**attrs, &)
7
+ end
8
+
9
+ private
10
+
11
+ def default_attrs
12
+ {
13
+ data: {slot: "description"},
14
+ class: "font-normal leading-[1.4] text-muted-foreground"
15
+ }
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class ToastDocs < Phlex::HTML
5
+ def view_template
6
+ div(class: "space-y-4") do
7
+ h2 { "Toast" }
8
+ p { "Hotwire-native sonner port. Mount once; trigger via Turbo Stream or window.RubyUI.toast." }
9
+ end
10
+ end
11
+ end
12
+ end