ruby_ui 1.1.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/ruby_ui/component_generator.rb +5 -1
  3. data/lib/generators/ruby_ui/dependencies.yml +32 -0
  4. data/lib/generators/ruby_ui/install/install_generator.rb +1 -1
  5. data/lib/generators/ruby_ui/install/templates/tailwind.css.erb +1 -1
  6. data/lib/generators/ruby_ui/javascript_utils.rb +27 -6
  7. data/lib/ruby_ui/avatar/avatar.rb +3 -0
  8. data/lib/ruby_ui/avatar/avatar_controller.js +33 -0
  9. data/lib/ruby_ui/avatar/avatar_fallback.rb +3 -0
  10. data/lib/ruby_ui/avatar/avatar_image.rb +4 -0
  11. data/lib/ruby_ui/base.rb +6 -0
  12. data/lib/ruby_ui/calendar/calendar.rb +3 -1
  13. data/lib/ruby_ui/calendar/calendar_controller.js +66 -7
  14. data/lib/ruby_ui/calendar/calendar_days.rb +20 -0
  15. data/lib/ruby_ui/calendar/calendar_docs.rb +9 -0
  16. data/lib/ruby_ui/combobox/combobox_badge.rb +17 -0
  17. data/lib/ruby_ui/combobox/combobox_badge_trigger.rb +47 -0
  18. data/lib/ruby_ui/combobox/combobox_clear_button.rb +40 -0
  19. data/lib/ruby_ui/combobox/combobox_controller.js +4 -2
  20. data/lib/ruby_ui/combobox/combobox_docs.rb +199 -64
  21. data/lib/ruby_ui/combobox/combobox_input_trigger.rb +64 -0
  22. data/lib/ruby_ui/combobox/combobox_item_indicator.rb +30 -0
  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/data_table/data_table.rb +29 -0
  29. data/lib/ruby_ui/data_table/data_table_bulk_actions.rb +18 -0
  30. data/lib/ruby_ui/data_table/data_table_column_toggle.rb +62 -0
  31. data/lib/ruby_ui/data_table/data_table_column_visibility_controller.js +14 -0
  32. data/lib/ruby_ui/data_table/data_table_controller.js +57 -0
  33. data/lib/ruby_ui/data_table/data_table_docs.rb +180 -0
  34. data/lib/ruby_ui/data_table/data_table_expand_toggle.rb +53 -0
  35. data/lib/ruby_ui/data_table/data_table_form.rb +39 -0
  36. data/lib/ruby_ui/data_table/data_table_kaminari_adapter.rb +17 -0
  37. data/lib/ruby_ui/data_table/data_table_manual_adapter.rb +17 -0
  38. data/lib/ruby_ui/data_table/data_table_pagination.rb +100 -0
  39. data/lib/ruby_ui/data_table/data_table_pagination_bar.rb +15 -0
  40. data/lib/ruby_ui/data_table/data_table_pagy_adapter.rb +17 -0
  41. data/lib/ruby_ui/data_table/data_table_per_page_select.rb +35 -0
  42. data/lib/ruby_ui/data_table/data_table_row_checkbox.rb +30 -0
  43. data/lib/ruby_ui/data_table/data_table_search.rb +57 -0
  44. data/lib/ruby_ui/data_table/data_table_search_controller.js +62 -0
  45. data/lib/ruby_ui/data_table/data_table_select_all_checkbox.rb +21 -0
  46. data/lib/ruby_ui/data_table/data_table_selection_summary.rb +25 -0
  47. data/lib/ruby_ui/data_table/data_table_sort_head.rb +112 -0
  48. data/lib/ruby_ui/data_table/data_table_toolbar.rb +15 -0
  49. data/lib/ruby_ui/date_picker/date_picker.rb +85 -0
  50. data/lib/ruby_ui/date_picker/date_picker_docs.rb +23 -0
  51. data/lib/ruby_ui/native_select/native_select.rb +39 -0
  52. data/lib/ruby_ui/native_select/native_select_docs.rb +83 -0
  53. data/lib/ruby_ui/native_select/native_select_group.rb +15 -0
  54. data/lib/ruby_ui/native_select/native_select_icon.rb +39 -0
  55. data/lib/ruby_ui/native_select/native_select_option.rb +15 -0
  56. data/lib/ruby_ui/select/select_value.rb +2 -1
  57. data/lib/ruby_ui/sheet/sheet.rb +9 -1
  58. data/lib/ruby_ui/sheet/sheet_controller.js +6 -0
  59. data/lib/ruby_ui/theme_toggle/theme_toggle.rb +14 -2
  60. data/lib/ruby_ui/theme_toggle/theme_toggle_controller.js +27 -19
  61. data/lib/ruby_ui/theme_toggle/theme_toggle_docs.rb +12 -42
  62. data/lib/ruby_ui/toast/toast.rb +18 -0
  63. data/lib/ruby_ui/toast/toast_action.rb +27 -0
  64. data/lib/ruby_ui/toast/toast_cancel.rb +27 -0
  65. data/lib/ruby_ui/toast/toast_close.rb +40 -0
  66. data/lib/ruby_ui/toast/toast_controller.js +151 -0
  67. data/lib/ruby_ui/toast/toast_description.rb +18 -0
  68. data/lib/ruby_ui/toast/toast_docs.rb +12 -0
  69. data/lib/ruby_ui/toast/toast_icon.rb +65 -0
  70. data/lib/ruby_ui/toast/toast_item.rb +72 -0
  71. data/lib/ruby_ui/toast/toast_region.rb +124 -0
  72. data/lib/ruby_ui/toast/toast_title.rb +18 -0
  73. data/lib/ruby_ui/toast/toaster_controller.js +306 -0
  74. data/lib/ruby_ui/toggle/toggle.rb +101 -0
  75. data/lib/ruby_ui/toggle/toggle_controller.js +33 -0
  76. data/lib/ruby_ui/toggle_group/toggle_group.rb +119 -0
  77. data/lib/ruby_ui/toggle_group/toggle_group_controller.js +126 -0
  78. data/lib/ruby_ui/toggle_group/toggle_group_item.rb +67 -0
  79. data/lib/ruby_ui/tooltip/tooltip_content.rb +12 -5
  80. data/lib/ruby_ui/tooltip/tooltip_controller.js +58 -22
  81. data/lib/ruby_ui/tooltip/tooltip_docs.rb +13 -0
  82. data/lib/ruby_ui/tooltip/tooltip_trigger.rb +10 -3
  83. data/lib/ruby_ui.rb +3 -1
  84. metadata +66 -10
  85. data/lib/ruby_ui/theme_toggle/set_dark_mode.rb +0 -16
  86. data/lib/ruby_ui/theme_toggle/set_light_mode.rb +0 -16
@@ -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
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class ToastIcon < Base
5
+ def initialize(variant: nil, **attrs)
6
+ @variant = variant&.to_sym
7
+ super(**attrs)
8
+ end
9
+
10
+ def view_template
11
+ return unless renderable?
12
+ span(**attrs) do
13
+ svg(
14
+ xmlns: "http://www.w3.org/2000/svg",
15
+ width: "16",
16
+ height: "16",
17
+ viewbox: "0 0 24 24",
18
+ fill: "none",
19
+ stroke: "currentColor",
20
+ stroke_width: "2",
21
+ stroke_linecap: "round",
22
+ stroke_linejoin: "round",
23
+ class: "#{svg_classes} -ml-px"
24
+ ) { |s| paths(s) }
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def renderable?
31
+ %i[success error warning info loading].include?(@variant)
32
+ end
33
+
34
+ def svg_classes
35
+ base = "size-4"
36
+ (@variant == :loading) ? "#{base} animate-spin" : base
37
+ end
38
+
39
+ def paths(s)
40
+ case @variant
41
+ when :success
42
+ s.circle(cx: "12", cy: "12", r: "10")
43
+ s.path(d: "m9 12 2 2 4-4")
44
+ when :error
45
+ s.path(d: "M2.586 16.726A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2h6.624a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586z")
46
+ s.path(d: "m15 9-6 6")
47
+ s.path(d: "m9 9 6 6")
48
+ when :warning
49
+ s.path(d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3")
50
+ s.path(d: "M12 9v4")
51
+ s.path(d: "M12 17h.01")
52
+ when :info
53
+ s.circle(cx: "12", cy: "12", r: "10")
54
+ s.path(d: "M12 16v-4")
55
+ s.path(d: "M12 8h.01")
56
+ when :loading
57
+ s.path(d: "M21 12a9 9 0 1 1-6.219-8.56")
58
+ end
59
+ end
60
+
61
+ def default_attrs
62
+ {data: {slot: "icon"}, class: "shrink-0 inline-flex items-center justify-start relative size-4 -ml-[3px] mr-1 text-foreground"}
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class ToastItem < Base
5
+ ALERT_VARIANTS = %i[error].freeze
6
+
7
+ def initialize(
8
+ variant: :default,
9
+ id: nil,
10
+ duration: nil,
11
+ dismissible: true,
12
+ invert: false,
13
+ on_dismiss: nil,
14
+ on_auto_close: nil,
15
+ **attrs
16
+ )
17
+ @variant = variant.to_sym
18
+ @id = id
19
+ @duration = duration
20
+ @dismissible = dismissible
21
+ @invert = invert
22
+ @on_dismiss = on_dismiss
23
+ @on_auto_close = on_auto_close
24
+ super(**attrs)
25
+ end
26
+
27
+ def view_template(&)
28
+ li(**attrs, &)
29
+ end
30
+
31
+ private
32
+
33
+ def default_attrs
34
+ a = {
35
+ role: ALERT_VARIANTS.include?(@variant) ? "alert" : "status",
36
+ aria_atomic: "true",
37
+ tabindex: "0",
38
+ data: {
39
+ variant: @variant.to_s,
40
+ state: "pending",
41
+ swipe: "none",
42
+ controller: "ruby-ui--toast",
43
+ ruby_ui__toaster_target: "toast",
44
+ ruby_ui__toast_dismissible_value: @dismissible.to_s,
45
+ ruby_ui__toast_invert_value: @invert.to_s
46
+ },
47
+ class: item_classes
48
+ }
49
+ a[:id] = @id if @id
50
+ a[:data][:ruby_ui__toast_duration_value] = @duration.to_s if @duration
51
+ a[:data][:ruby_ui__toast_on_dismiss_value] = @on_dismiss if @on_dismiss
52
+ a[:data][:ruby_ui__toast_on_auto_close_value] = @on_auto_close if @on_auto_close
53
+ a
54
+ end
55
+
56
+ def item_classes
57
+ <<~CLASSES.tr("\n", " ").squeeze(" ").strip
58
+ group/toast pointer-events-auto absolute left-0 right-0
59
+ flex w-[356px] max-w-full items-center gap-1.5
60
+ rounded-lg border bg-popover text-popover-foreground
61
+ border-border p-4 text-[13px] shadow-[0_4px_12px_rgba(0,0,0,0.1)]
62
+ group-data-[close-button=true]/toaster:pr-10
63
+ transition-[transform,opacity] duration-300 ease-out
64
+ will-change-transform
65
+ opacity-[var(--opacity,1)]
66
+ data-[state=pending]:opacity-0
67
+ data-[state=closing]:opacity-0
68
+ data-[swipe=move]:transition-none
69
+ CLASSES
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class ToastRegion < Base
5
+ SKELETON_VARIANTS = %i[default success error warning info loading].freeze
6
+
7
+ def initialize(
8
+ position: :bottom_right,
9
+ expand: false,
10
+ max: 3,
11
+ duration: 4000,
12
+ gap: 14,
13
+ offset: 24,
14
+ theme: :system,
15
+ rich_colors: false,
16
+ close_button: false,
17
+ hotkey: %w[alt t],
18
+ dir: :ltr,
19
+ flash: nil,
20
+ **attrs
21
+ )
22
+ @position = position.to_sym
23
+ @expand = expand
24
+ @max = max
25
+ @duration = duration
26
+ @gap = gap
27
+ @offset = offset
28
+ @theme = theme.to_sym
29
+ @rich_colors = rich_colors
30
+ @close_button = close_button
31
+ @hotkey = hotkey
32
+ @dir = dir
33
+ @flash = flash
34
+ super(**attrs)
35
+ end
36
+
37
+ def view_template(&block)
38
+ div(**attrs) do
39
+ ol(id: "ruby-ui-toaster", class: "pointer-events-auto relative m-0 p-0 list-none w-[356px] max-w-full") do
40
+ render_flash if @flash
41
+ yield(self) if block
42
+ end
43
+ SKELETON_VARIANTS.each { |v| skeleton(v) }
44
+ slot_template("actionTpl") { render RubyUI::ToastAction.new(label: "") }
45
+ slot_template("cancelTpl") { render RubyUI::ToastCancel.new(label: "") }
46
+ slot_template("closeTpl") { render RubyUI::ToastClose.new }
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def render_flash
53
+ @flash.each do |key, message|
54
+ next if message.nil? || message.to_s.empty?
55
+ variant = RubyUI::Toast.flash_variant(key)
56
+ render RubyUI::ToastItem.new(variant: variant, id: "flash-#{key}") do
57
+ render RubyUI::ToastIcon.new(variant: variant)
58
+ render RubyUI::ToastTitle.new { message.to_s }
59
+ end
60
+ end
61
+ end
62
+
63
+ def skeleton(variant)
64
+ template(
65
+ data: {
66
+ ruby_ui__toaster_target: "skeleton",
67
+ variant: variant.to_s
68
+ }
69
+ ) do
70
+ render RubyUI::ToastItem.new(variant: variant) do
71
+ render RubyUI::ToastIcon.new(variant: variant)
72
+ div(class: "flex flex-col gap-0.5 flex-1 min-w-0") do
73
+ render RubyUI::ToastTitle.new
74
+ render RubyUI::ToastDescription.new
75
+ end
76
+ render RubyUI::ToastClose.new if @close_button
77
+ end
78
+ end
79
+ end
80
+
81
+ def slot_template(target_name, &)
82
+ template(data: {ruby_ui__toaster_target: target_name}, &)
83
+ end
84
+
85
+ def default_attrs
86
+ {
87
+ id: "ruby-ui-toaster-region",
88
+ role: "region",
89
+ aria_label: "Notifications",
90
+ aria_live: "polite",
91
+ data: {
92
+ controller: "ruby-ui--toaster",
93
+ turbo_permanent: "",
94
+ close_button: @close_button.to_s,
95
+ position: @position.to_s.tr("_", "-"),
96
+ ruby_ui__toaster_position_value: @position.to_s.tr("_", "-"),
97
+ ruby_ui__toaster_expand_value: @expand.to_s,
98
+ ruby_ui__toaster_max_value: @max.to_s,
99
+ ruby_ui__toaster_duration_value: @duration.to_s,
100
+ ruby_ui__toaster_gap_value: @gap.to_s,
101
+ ruby_ui__toaster_offset_value: @offset.to_s,
102
+ ruby_ui__toaster_theme_value: @theme.to_s,
103
+ ruby_ui__toaster_rich_colors_value: @rich_colors.to_s,
104
+ ruby_ui__toaster_close_button_value: @close_button.to_s,
105
+ ruby_ui__toaster_hotkey_value: Array(@hotkey).join("+"),
106
+ ruby_ui__toaster_dir_value: @dir.to_s
107
+ },
108
+ class: region_classes
109
+ }
110
+ end
111
+
112
+ def region_classes
113
+ <<~CLASSES.tr("\n", " ").squeeze(" ").strip
114
+ group/toaster pointer-events-none fixed z-[100] p-4 sm:p-6
115
+ data-[position=top-left]:top-0 data-[position=top-left]:left-0
116
+ data-[position=top-center]:top-0 data-[position=top-center]:left-1/2 data-[position=top-center]:-translate-x-1/2
117
+ data-[position=top-right]:top-0 data-[position=top-right]:right-0
118
+ data-[position=bottom-left]:bottom-0 data-[position=bottom-left]:left-0
119
+ data-[position=bottom-center]:bottom-0 data-[position=bottom-center]:left-1/2 data-[position=bottom-center]:-translate-x-1/2
120
+ data-[position=bottom-right]:bottom-0 data-[position=bottom-right]:right-0
121
+ CLASSES
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class ToastTitle < Base
5
+ def view_template(&)
6
+ div(**attrs, &)
7
+ end
8
+
9
+ private
10
+
11
+ def default_attrs
12
+ {
13
+ data: {slot: "title"},
14
+ class: "font-medium leading-normal"
15
+ }
16
+ end
17
+ end
18
+ end