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.
- checksums.yaml +4 -4
- data/lib/generators/ruby_ui/dependencies.yml +32 -10
- data/lib/generators/ruby_ui/install/templates/tailwind.css.erb +1 -1
- data/lib/generators/ruby_ui/javascript_utils.rb +24 -7
- data/lib/ruby_ui/avatar/avatar.rb +3 -0
- data/lib/ruby_ui/avatar/avatar_controller.js +33 -0
- data/lib/ruby_ui/avatar/avatar_fallback.rb +3 -0
- data/lib/ruby_ui/avatar/avatar_image.rb +4 -0
- data/lib/ruby_ui/base.rb +6 -0
- data/lib/ruby_ui/calendar/calendar.rb +3 -1
- data/lib/ruby_ui/calendar/calendar_controller.js +66 -7
- data/lib/ruby_ui/calendar/calendar_days.rb +20 -0
- data/lib/ruby_ui/calendar/calendar_docs.rb +9 -0
- data/lib/ruby_ui/combobox/combobox.rb +1 -7
- data/lib/ruby_ui/combobox/combobox_checkbox.rb +7 -1
- data/lib/ruby_ui/combobox/combobox_controller.js +56 -244
- data/lib/ruby_ui/combobox/combobox_item.rb +7 -5
- data/lib/ruby_ui/combobox/combobox_list_group.rb +1 -1
- data/lib/ruby_ui/combobox/combobox_popover.rb +5 -0
- data/lib/ruby_ui/combobox/combobox_radio.rb +8 -1
- data/lib/ruby_ui/combobox/combobox_toggle_all_checkbox.rb +7 -1
- data/lib/ruby_ui/combobox/combobox_trigger.rb +19 -19
- data/lib/ruby_ui/command/command_controller.js +10 -19
- data/lib/ruby_ui/command/command_dialog.rb +4 -1
- data/lib/ruby_ui/command/command_dialog_content.rb +2 -2
- data/lib/ruby_ui/command/command_dialog_controller.js +34 -0
- data/lib/ruby_ui/command/command_dialog_trigger.rb +2 -2
- data/lib/ruby_ui/date_picker/date_picker.rb +85 -0
- data/lib/ruby_ui/date_picker/date_picker_docs.rb +23 -0
- data/lib/ruby_ui/masked_input/masked_input.rb +1 -11
- data/lib/ruby_ui/masked_input/masked_input_controller.js +0 -13
- data/lib/ruby_ui/select/select_value.rb +2 -1
- data/lib/ruby_ui/sheet/sheet.rb +9 -1
- data/lib/ruby_ui/sheet/sheet_controller.js +6 -0
- data/lib/ruby_ui/theme_toggle/theme_toggle.rb +14 -2
- data/lib/ruby_ui/theme_toggle/theme_toggle_controller.js +27 -19
- data/lib/ruby_ui/theme_toggle/theme_toggle_docs.rb +12 -42
- data/lib/ruby_ui/toast/toast.rb +18 -0
- data/lib/ruby_ui/toast/toast_action.rb +27 -0
- data/lib/ruby_ui/toast/toast_cancel.rb +27 -0
- data/lib/ruby_ui/toast/toast_close.rb +40 -0
- data/lib/ruby_ui/toast/toast_controller.js +151 -0
- data/lib/ruby_ui/toast/toast_description.rb +18 -0
- data/lib/ruby_ui/toast/toast_docs.rb +12 -0
- data/lib/ruby_ui/toast/toast_icon.rb +65 -0
- data/lib/ruby_ui/toast/toast_item.rb +72 -0
- data/lib/ruby_ui/toast/toast_region.rb +124 -0
- data/lib/ruby_ui/toast/toast_title.rb +18 -0
- data/lib/ruby_ui/toast/toaster_controller.js +306 -0
- data/lib/ruby_ui/toggle/toggle.rb +101 -0
- data/lib/ruby_ui/toggle/toggle_controller.js +33 -0
- data/lib/ruby_ui/toggle_group/toggle_group.rb +119 -0
- data/lib/ruby_ui/toggle_group/toggle_group_controller.js +126 -0
- data/lib/ruby_ui/toggle_group/toggle_group_item.rb +67 -0
- data/lib/ruby_ui/tooltip/tooltip_content.rb +12 -5
- data/lib/ruby_ui/tooltip/tooltip_controller.js +58 -22
- data/lib/ruby_ui/tooltip/tooltip_docs.rb +13 -0
- data/lib/ruby_ui/tooltip/tooltip_trigger.rb +10 -3
- data/lib/ruby_ui.rb +3 -1
- metadata +30 -14
- data/lib/ruby_ui/theme_toggle/set_dark_mode.rb +0 -16
- 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
|