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
@@ -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
@@ -0,0 +1,306 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const VARIANTS = ["default", "success", "error", "warning", "info", "loading"]
4
+
5
+ let streamActionRegistered = false
6
+
7
+ function registerStreamAction() {
8
+ if (streamActionRegistered) return
9
+ if (typeof window === "undefined") return
10
+ const Turbo = window.Turbo
11
+ if (!Turbo?.StreamActions) return
12
+ Turbo.StreamActions.toast = function () {
13
+ const detail = {}
14
+ for (const attr of this.attributes) {
15
+ if (attr.name === "action" || attr.name === "target" || attr.name === "targets") continue
16
+ detail[attr.name] = attr.value
17
+ }
18
+ if (detail.duration != null && detail.duration !== "") detail.duration = Number(detail.duration)
19
+ if (detail.dismissible != null) detail.dismissible = detail.dismissible !== "false"
20
+ window.dispatchEvent(new CustomEvent("ruby-ui:toast", { detail }))
21
+ }
22
+ streamActionRegistered = true
23
+ }
24
+
25
+ // Connects to data-controller="ruby-ui--toaster"
26
+ export default class extends Controller {
27
+ static targets = ["skeleton", "toast", "actionTpl", "cancelTpl", "closeTpl"]
28
+ static values = {
29
+ position: { type: String, default: "bottom-right" },
30
+ expand: { type: Boolean, default: false },
31
+ max: { type: Number, default: 3 },
32
+ duration: { type: Number, default: 4000 },
33
+ gap: { type: Number, default: 14 },
34
+ offset: { type: Number, default: 24 },
35
+ theme: { type: String, default: "system" },
36
+ richColors: { type: Boolean, default: false },
37
+ closeButton: { type: Boolean, default: false },
38
+ hotkey: { type: String, default: "alt+t" },
39
+ dir: { type: String, default: "ltr" },
40
+ }
41
+
42
+ connect() {
43
+ this._heights = new Map()
44
+ this._resizeObservers = new WeakMap()
45
+ this._expanded = this.expandValue
46
+ this._listEl = this.element.querySelector("ol") || (this.element.tagName === "OL" ? this.element : null)
47
+ this._registerGlobalApi()
48
+ registerStreamAction()
49
+ if (!this._listEl) return
50
+
51
+ this._onPointerEnter = () => this._setExpanded(true)
52
+ this._onPointerLeave = () => { if (!this.expandValue) this._setExpanded(false) }
53
+ this._onWindowToast = (e) => this._spawn(e.detail || {})
54
+ this._onWindowDismissAll = () => this._dismissById(null)
55
+ this._onKey = this._onKey.bind(this)
56
+
57
+ window.addEventListener("ruby-ui:toast", this._onWindowToast)
58
+ window.addEventListener("ruby-ui:toast:dismiss-all", this._onWindowDismissAll)
59
+ this._listEl.addEventListener("pointerenter", this._onPointerEnter)
60
+ this._listEl.addEventListener("pointerleave", this._onPointerLeave)
61
+ document.addEventListener("keydown", this._onKey)
62
+ }
63
+
64
+ disconnect() {
65
+ window.removeEventListener("ruby-ui:toast", this._onWindowToast)
66
+ window.removeEventListener("ruby-ui:toast:dismiss-all", this._onWindowDismissAll)
67
+ this._listEl?.removeEventListener("pointerenter", this._onPointerEnter)
68
+ this._listEl?.removeEventListener("pointerleave", this._onPointerLeave)
69
+ document.removeEventListener("keydown", this._onKey)
70
+ }
71
+
72
+ toastTargetConnected(el) {
73
+ if (typeof ResizeObserver !== "undefined") {
74
+ const ro = new ResizeObserver(() => {
75
+ this._heights.set(el, el.offsetHeight)
76
+ this._reflow()
77
+ })
78
+ ro.observe(el)
79
+ this._resizeObservers.set(el, ro)
80
+ }
81
+ this._heights.set(el, el.offsetHeight || 64)
82
+ this._reflow()
83
+ }
84
+
85
+ toastTargetDisconnected(el) {
86
+ this._resizeObservers.get(el)?.disconnect()
87
+ this._resizeObservers.delete(el)
88
+ this._heights.delete(el)
89
+ this._reflow()
90
+ }
91
+
92
+ _spawn(detail) {
93
+ const variant = VARIANTS.includes(detail.variant) ? detail.variant : "default"
94
+ const tpl = this._skeletonFor(variant)
95
+ if (!tpl) return null
96
+ if (detail.position) {
97
+ this.element.setAttribute("data-position", detail.position)
98
+ this.positionValue = detail.position
99
+ }
100
+ const node = tpl.content.firstElementChild.cloneNode(true)
101
+
102
+ node.id = detail.id || `toast-${this._uuid()}`
103
+ if (detail.duration != null) {
104
+ const dur = detail.duration === Infinity ? 0 : detail.duration
105
+ node.setAttribute("data-ruby-ui--toast-duration-value", String(dur))
106
+ }
107
+ if (detail.dismissible === false) {
108
+ node.setAttribute("data-ruby-ui--toast-dismissible-value", "false")
109
+ }
110
+ if (detail.className) node.className += ` ${detail.className}`
111
+
112
+ const titleEl = node.querySelector('[data-slot="title"]')
113
+ if (titleEl) titleEl.textContent = detail.title || detail.message || ""
114
+ const descEl = node.querySelector('[data-slot="description"]')
115
+ if (descEl) {
116
+ if (detail.description) descEl.textContent = detail.description
117
+ else descEl.remove()
118
+ }
119
+
120
+ if (detail.action && detail.action.label && this.hasActionTplTarget) {
121
+ const btn = this._cloneSlot(this.actionTplTarget)
122
+ btn.textContent = detail.action.label
123
+ btn.addEventListener("click", (ev) => {
124
+ try { detail.action.onClick?.(ev) } finally {
125
+ node.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true }))
126
+ }
127
+ })
128
+ node.appendChild(btn)
129
+ }
130
+
131
+ if (detail.cancel && detail.cancel.label && this.hasCancelTplTarget) {
132
+ const btn = this._cloneSlot(this.cancelTplTarget)
133
+ btn.textContent = detail.cancel.label
134
+ node.appendChild(btn)
135
+ }
136
+
137
+ if (detail.closeButton && this.hasCloseTplTarget) {
138
+ const x = this._cloneSlot(this.closeTplTarget)
139
+ node.classList.add("pr-10")
140
+ node.appendChild(x)
141
+ }
142
+
143
+ this._listEl.appendChild(node)
144
+ return node.id
145
+ }
146
+
147
+ _dismissById(id) {
148
+ if (!id) {
149
+ this.toastTargets.forEach((el) =>
150
+ el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true }))
151
+ )
152
+ return
153
+ }
154
+ const el = this._listEl.querySelector(`#${CSS.escape(id)}`)
155
+ if (el) el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true }))
156
+ }
157
+
158
+ _skeletonFor(variant) {
159
+ return this.skeletonTargets.find((t) => t.dataset.variant === variant)
160
+ }
161
+
162
+ _cloneSlot(tpl) {
163
+ return tpl.content.firstElementChild.cloneNode(true)
164
+ }
165
+
166
+ _setExpanded(value) {
167
+ if (this._expanded === value) return
168
+ this._expanded = value
169
+ document.dispatchEvent(new CustomEvent(value ? "ruby-ui:toast:pause" : "ruby-ui:toast:resume"))
170
+ this._reflow()
171
+ }
172
+
173
+ _reflow() {
174
+ if (!this._listEl) return
175
+ const isBottom = this.positionValue.startsWith("bottom")
176
+ const items = this.toastTargets
177
+ const order = isBottom ? items.slice().reverse() : items.slice()
178
+ const heights = order.map(el => this._heights.get(el) || el.offsetHeight || 64)
179
+ const gap = this.gapValue
180
+ const peekOffset = 16
181
+ const peekScaleStep = 0.05
182
+ const peekOpacityStep = 0.2
183
+
184
+ const expandedHeight = heights.reduce((a, b) => a + b, 0) + gap * Math.max(0, heights.length - 1)
185
+ const collapsedHeight = (heights[0] || 0) + Math.min(2, Math.max(0, heights.length - 1)) * peekOffset
186
+ this._listEl.style.minHeight = `${this._expanded ? expandedHeight : collapsedHeight}px`
187
+
188
+ let acc = 0
189
+ order.forEach((el, i) => {
190
+ const visible = i < this.maxValue
191
+ let yOffset, scale, opacity
192
+
193
+ if (this._expanded) {
194
+ yOffset = acc + i * gap
195
+ scale = 1
196
+ opacity = visible ? 1 : 0
197
+ } else {
198
+ yOffset = i * peekOffset
199
+ scale = Math.max(0.85, 1 - i * peekScaleStep)
200
+ opacity = visible ? Math.max(0, 1 - i * peekOpacityStep) : 0
201
+ }
202
+
203
+ const sign = isBottom ? -1 : 1
204
+ const ty = sign * yOffset
205
+
206
+ el.style.setProperty("--opacity", String(opacity))
207
+ el.style.setProperty("--scale", String(scale))
208
+ el.style.setProperty("--y-offset", `${ty}px`)
209
+ el.style.transformOrigin = isBottom ? "center bottom" : "center top"
210
+ el.style.top = isBottom ? "auto" : "0"
211
+ el.style.bottom = isBottom ? "0" : "auto"
212
+ el.style.transform = `translate3d(0, ${ty}px, 0) scale(${scale})`
213
+ el.style.zIndex = String(1000 - i)
214
+ el.style.pointerEvents = visible ? "auto" : "none"
215
+ el.tabIndex = visible ? 0 : -1
216
+
217
+ acc += heights[i] || 0
218
+ })
219
+
220
+ this._enforceMax(items)
221
+ }
222
+
223
+ _enforceMax(items) {
224
+ if (items.length <= this.maxValue) return
225
+ const isBottom = this.positionValue.startsWith("bottom")
226
+ const dropping = items.length - this.maxValue
227
+ const candidates = isBottom ? items.slice(0, dropping) : items.slice(-dropping)
228
+ candidates.forEach(el => {
229
+ if (el.dataset.state !== "closing") {
230
+ el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true }))
231
+ }
232
+ })
233
+ }
234
+
235
+ _onKey(e) {
236
+ const parts = (this.hotkeyValue || "alt+t").split("+")
237
+ const key = parts.pop()
238
+ const wantAlt = parts.includes("alt")
239
+ const wantCtrl = parts.includes("ctrl")
240
+ const wantMeta = parts.includes("meta")
241
+ if (e.key.toLowerCase() !== key.toLowerCase()) return
242
+ if (wantAlt !== e.altKey) return
243
+ if (wantCtrl !== e.ctrlKey) return
244
+ if (wantMeta !== e.metaKey) return
245
+ e.preventDefault()
246
+ const first = this._listEl.firstElementChild
247
+ first?.focus()
248
+ }
249
+
250
+ _registerGlobalApi() {
251
+ const fire = (variant, message, opts = {}) =>
252
+ window.dispatchEvent(new CustomEvent("ruby-ui:toast", {
253
+ detail: { ...opts, variant, message: opts.title || message }
254
+ }))
255
+
256
+ const api = (message, opts) => fire("default", message, opts)
257
+ api.success = (m, o) => fire("success", m, o)
258
+ api.error = (m, o) => fire("error", m, o)
259
+ api.warning = (m, o) => fire("warning", m, o)
260
+ api.info = (m, o) => fire("info", m, o)
261
+ api.loading = (m, o = {}) => fire("loading", m, { ...o, duration: o.duration ?? 0 })
262
+ api.dismiss = (id) => {
263
+ if (id) this._dismissById(id)
264
+ else window.dispatchEvent(new CustomEvent("ruby-ui:toast:dismiss-all"))
265
+ }
266
+ api.promise = (p, msgs = {}) => {
267
+ const id = `toast-${this._uuid()}`
268
+ fire("loading", typeof msgs.loading === "function" ? msgs.loading() : (msgs.loading || "Loading..."), { id, duration: 0 })
269
+ Promise.resolve(p).then(
270
+ (val) => this._mutate(id, "success", typeof msgs.success === "function" ? msgs.success(val) : msgs.success),
271
+ (err) => this._mutate(id, "error", typeof msgs.error === "function" ? msgs.error(err) : msgs.error)
272
+ )
273
+ return id
274
+ }
275
+
276
+ window.RubyUI = window.RubyUI || {}
277
+ window.RubyUI.toast = api
278
+ }
279
+
280
+ _mutate(id, variant, text) {
281
+ const el = this._listEl.querySelector(`#${CSS.escape(id)}`)
282
+ if (!el) return
283
+ el.dataset.variant = variant
284
+ el.setAttribute("role", variant === "error" ? "alert" : "status")
285
+ this._swapIcon(el, variant)
286
+ const t = el.querySelector('[data-slot="title"]')
287
+ if (t && text) t.textContent = text
288
+ const dur = String(this.durationValue)
289
+ el.setAttribute("data-ruby-ui--toast-duration-value", dur)
290
+ el.dispatchEvent(new CustomEvent("ruby-ui:toast:restart", { bubbles: true }))
291
+ }
292
+
293
+ _swapIcon(el, variant) {
294
+ const iconHost = el.querySelector('[data-slot="icon"]')
295
+ if (!iconHost) return
296
+ const tpl = this._skeletonFor(variant)
297
+ if (!tpl) return
298
+ const sourceIcon = tpl.content.firstElementChild?.querySelector('[data-slot="icon"]')
299
+ iconHost.innerHTML = sourceIcon ? sourceIcon.innerHTML : ""
300
+ }
301
+
302
+ _uuid() {
303
+ if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID()
304
+ return Math.random().toString(36).slice(2) + Date.now().toString(36)
305
+ }
306
+ }
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class Toggle < Base
5
+ BASE_CLASSES = [
6
+ "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap",
7
+ "transition-[color,box-shadow] outline-none",
8
+ "hover:bg-muted hover:text-muted-foreground",
9
+ "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
10
+ "disabled:pointer-events-none disabled:opacity-50",
11
+ "aria-invalid:border-destructive aria-invalid:ring-destructive/20",
12
+ "data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
13
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
14
+ ].freeze
15
+
16
+ VARIANT_CLASSES = {
17
+ default: "bg-transparent",
18
+ outline: "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground"
19
+ }.freeze
20
+
21
+ SIZE_CLASSES = {
22
+ sm: "h-8 min-w-8 px-1.5",
23
+ default: "h-9 min-w-9 px-2",
24
+ lg: "h-10 min-w-10 px-2.5"
25
+ }.freeze
26
+
27
+ def self.classes_for(variant:, size:)
28
+ [BASE_CLASSES, VARIANT_CLASSES.fetch(variant, VARIANT_CLASSES[:default]), SIZE_CLASSES.fetch(size, SIZE_CLASSES[:default])]
29
+ end
30
+
31
+ def initialize(
32
+ pressed: false,
33
+ name: nil,
34
+ value: "1",
35
+ unpressed_value: nil,
36
+ variant: :default,
37
+ size: :default,
38
+ disabled: false,
39
+ wrapper: {},
40
+ **attrs
41
+ )
42
+ @pressed = pressed
43
+ @name = name
44
+ @value = value
45
+ @unpressed_value = unpressed_value
46
+ @variant = variant.to_sym
47
+ @size = size.to_sym
48
+ @disabled = disabled
49
+ @wrapper = wrapper
50
+ super(**attrs)
51
+ end
52
+
53
+ def view_template(&block)
54
+ span(**wrapper_attrs) do
55
+ button(**attrs, &block)
56
+ render_hidden_input if @name
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def wrapper_attrs
63
+ mix(wrapper_default_attrs, @wrapper)
64
+ end
65
+
66
+ def wrapper_default_attrs
67
+ {
68
+ class: "contents",
69
+ data: {
70
+ controller: "ruby-ui--toggle",
71
+ action: "click->ruby-ui--toggle#toggle",
72
+ "ruby-ui--toggle-pressed-value": @pressed.to_s,
73
+ "ruby-ui--toggle-value-value": @value.to_s,
74
+ "ruby-ui--toggle-unpressed-value-value": @unpressed_value.to_s
75
+ }
76
+ }
77
+ end
78
+
79
+ def render_hidden_input
80
+ input(
81
+ type: "hidden",
82
+ name: @name,
83
+ value: @pressed ? @value : @unpressed_value.to_s,
84
+ data: {"ruby-ui--toggle-target": "input"}
85
+ )
86
+ end
87
+
88
+ def default_attrs
89
+ base = {type: "button"}
90
+ base[:disabled] = true if @disabled
91
+ base.merge(
92
+ aria: {pressed: @pressed.to_s},
93
+ data: {
94
+ state: @pressed ? "on" : "off",
95
+ "ruby-ui--toggle-target": "button"
96
+ },
97
+ class: self.class.classes_for(variant: @variant, size: @size)
98
+ )
99
+ end
100
+ end
101
+ end