uikit_rails 0.1.2 → 0.1.3
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/README.md +1 -0
- data/lib/uikit_rails/templates/components/toast/USAGE +59 -0
- data/lib/uikit_rails/templates/components/toast/component.html.erb +40 -0
- data/lib/uikit_rails/templates/components/toast/component.rb +102 -0
- data/lib/uikit_rails/templates/components/toast/preview.yml +96 -0
- data/lib/uikit_rails/templates/components/toast/toast.css +241 -0
- data/lib/uikit_rails/templates/stimulus/toast_controller.js +38 -0
- data/lib/uikit_rails/version.rb +1 -1
- data/lib/uikit_rails.rb +1 -0
- data/test_app/Gemfile.lock +2 -2
- data/test_app/app/assets/stylesheets/ui/toast.css +241 -0
- data/test_app/app/components/ui/toast/USAGE +59 -0
- data/test_app/app/components/ui/toast/component.html.erb +40 -0
- data/test_app/app/components/ui/toast/component.rb +102 -0
- data/test_app/app/components/ui/toast/preview.yml +96 -0
- data/test_app/app/javascript/controllers/ui/toast_controller.js +38 -0
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2f91b649381153711d5c2f5a78ad89d5d739504df3158e1ce81635fdc3f2e97c
|
|
4
|
+
data.tar.gz: 3f808888f9f379ef227fe9513ffa2978605eccc18370f2be66a5dc65103222d8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b2016553a1fc9de10ba54315362484a98bd6db0a8e661612712049d28cc3f66f825710dec2c14edb9d850d50422cb8c89ef9aa33361202fff676014181ca993a
|
|
7
|
+
data.tar.gz: 592e733bdb3f46e3ebdd710cfc894623a573d43c65caef6ebfa48fc03427db0cddabd04dacd16530f422190175fc8809c5508349fe2890ba4e0f60cd0787154b
|
data/README.md
CHANGED
|
@@ -187,6 +187,7 @@ The styleguide is self-contained — it has its own layout and styles that won't
|
|
|
187
187
|
| `popover` | Floating content panel triggered by click |
|
|
188
188
|
| `tabs` | Tabbed content panels with keyboard navigation |
|
|
189
189
|
| `tooltip` | Hover/focus tooltip (top, bottom, left, right) |
|
|
190
|
+
| `toast` | Dismissible notifications; `position:` sets fixed corner/center (six anchors), optional auto-dismiss (Stimulus) |
|
|
190
191
|
| `accordion` | Collapsible sections with single or multiple open |
|
|
191
192
|
| `collapsible` | Simple expand/collapse toggle |
|
|
192
193
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Basic toast:
|
|
2
|
+
|
|
3
|
+
<%= render Ui::Toast::Component.new do |toast| %>
|
|
4
|
+
<% toast.with_title do %>Done<% end %>
|
|
5
|
+
<% toast.with_description do %>Operation completed.<% end %>
|
|
6
|
+
<% end %>
|
|
7
|
+
|
|
8
|
+
Variants (default, success, destructive, warning):
|
|
9
|
+
|
|
10
|
+
<%= render Ui::Toast::Component.new(variant: :success) do |toast| %>
|
|
11
|
+
<% toast.with_description do %>Saved.<% end %>
|
|
12
|
+
<% end %>
|
|
13
|
+
|
|
14
|
+
Without dismiss button:
|
|
15
|
+
|
|
16
|
+
<%= render Ui::Toast::Component.new(dismissible: false) do |toast| %>
|
|
17
|
+
<% toast.with_description do %>Processing…<% end %>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
With auto-dismiss (duration_ms; requires ui--toast Stimulus):
|
|
21
|
+
|
|
22
|
+
<%= render Ui::Toast::Component.new(duration_ms: 4000) do |toast| %>
|
|
23
|
+
<% toast.with_description do %>Session expires soon.<% end %>
|
|
24
|
+
<% end %>
|
|
25
|
+
|
|
26
|
+
Stack multiple toasts — wrap in a viewport (see toast.css), omit position on each toast:
|
|
27
|
+
|
|
28
|
+
<div class="ui-toast-viewport ui-toast-viewport--bottom-right" aria-live="polite" aria-label="Notifications">
|
|
29
|
+
<%= render Ui::Toast::Component.new(variant: :success) do |toast| %>
|
|
30
|
+
<% toast.with_description do %>First<% end %>
|
|
31
|
+
<% end %>
|
|
32
|
+
<%= render Ui::Toast::Component.new do |toast| %>
|
|
33
|
+
<% toast.with_description do %>Second<% end %>
|
|
34
|
+
<% end %>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
Single toast with fixed position (wraps in .ui-toast-viewport for you):
|
|
38
|
+
|
|
39
|
+
position: :top_left, :top_center, :top_right, :bottom_left, :bottom_center, :bottom_right
|
|
40
|
+
|
|
41
|
+
<%= render Ui::Toast::Component.new(position: :top_center) do |toast| %>
|
|
42
|
+
<% toast.with_description do %>Centered along the top edge.<% end %>
|
|
43
|
+
<% end %>
|
|
44
|
+
|
|
45
|
+
For in-flow previews (not fixed to the window), add ui-toast-viewport--inline on the viewport
|
|
46
|
+
or viewport_html_attrs: { class: "ui-toast-viewport--inline" } when using position:.
|
|
47
|
+
|
|
48
|
+
Slots:
|
|
49
|
+
title — Optional heading
|
|
50
|
+
description — Optional supporting text
|
|
51
|
+
content — Default slot for extra markup
|
|
52
|
+
|
|
53
|
+
Close button uses data-action="click->ui--toast#dismiss" (removes the node after a short animation).
|
|
54
|
+
|
|
55
|
+
With custom attributes:
|
|
56
|
+
|
|
57
|
+
<%= render Ui::Toast::Component.new(class: "extra", data: { testid: "notice" }) do |toast| %>
|
|
58
|
+
<% toast.with_title do %>Heads up<% end %>
|
|
59
|
+
<% end %>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<% toast_inner = capture do %>
|
|
2
|
+
<% if show_icon? %>
|
|
3
|
+
<div class="ui-toast__icon" aria-hidden="true">
|
|
4
|
+
<% case variant
|
|
5
|
+
when :success %>
|
|
6
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
|
7
|
+
<% when :destructive %>
|
|
8
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>
|
|
9
|
+
<% when :warning %>
|
|
10
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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-3Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/></svg>
|
|
11
|
+
<% end %>
|
|
12
|
+
</div>
|
|
13
|
+
<% end %>
|
|
14
|
+
<div class="ui-toast__main">
|
|
15
|
+
<% if title? %>
|
|
16
|
+
<div class="ui-toast__title"><%= title %></div>
|
|
17
|
+
<% end %>
|
|
18
|
+
<% if description? %>
|
|
19
|
+
<div class="ui-toast__description"><%= description %></div>
|
|
20
|
+
<% end %>
|
|
21
|
+
<%= content %>
|
|
22
|
+
</div>
|
|
23
|
+
<% if dismissible %>
|
|
24
|
+
<button type="button" class="ui-toast__close" data-action="click->ui--toast#dismiss" aria-label="Dismiss notification">
|
|
25
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
26
|
+
</button>
|
|
27
|
+
<% end %>
|
|
28
|
+
<% end %>
|
|
29
|
+
|
|
30
|
+
<% if wrap_in_viewport? %>
|
|
31
|
+
<%= content_tag :div, **viewport_computed_attrs do %>
|
|
32
|
+
<%= content_tag :div, **computed_attrs do %>
|
|
33
|
+
<%= toast_inner %>
|
|
34
|
+
<% end %>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% else %>
|
|
37
|
+
<%= content_tag :div, **computed_attrs do %>
|
|
38
|
+
<%= toast_inner %>
|
|
39
|
+
<% end %>
|
|
40
|
+
<% end %>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ui
|
|
4
|
+
module Toast
|
|
5
|
+
# Brief notification with optional title, description, dismiss control, auto-dismiss,
|
|
6
|
+
# and optional fixed viewport wrapper (six screen positions).
|
|
7
|
+
class Component < Ui::BaseComponent
|
|
8
|
+
VARIANTS = %i[default success destructive warning].freeze
|
|
9
|
+
|
|
10
|
+
POSITIONS = %i[
|
|
11
|
+
top_left
|
|
12
|
+
top_center
|
|
13
|
+
top_right
|
|
14
|
+
bottom_left
|
|
15
|
+
bottom_center
|
|
16
|
+
bottom_right
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
renders_one :title
|
|
20
|
+
renders_one :description
|
|
21
|
+
|
|
22
|
+
attr_reader :variant, :dismissible, :duration_ms, :position, :viewport_html_attrs, :html_attrs
|
|
23
|
+
|
|
24
|
+
# rubocop:disable Metrics/ParameterLists -- explicit keywords match ViewComponent / ERB call style
|
|
25
|
+
def initialize(
|
|
26
|
+
variant: :default,
|
|
27
|
+
dismissible: true,
|
|
28
|
+
duration_ms: nil,
|
|
29
|
+
position: nil,
|
|
30
|
+
viewport_html_attrs: {},
|
|
31
|
+
**html_attrs
|
|
32
|
+
)
|
|
33
|
+
@variant = variant.to_sym
|
|
34
|
+
@dismissible = dismissible
|
|
35
|
+
@duration_ms = duration_ms&.to_i
|
|
36
|
+
@position = position.nil? ? nil : normalize_position(position)
|
|
37
|
+
@viewport_html_attrs = viewport_html_attrs
|
|
38
|
+
@html_attrs = html_attrs
|
|
39
|
+
super()
|
|
40
|
+
end
|
|
41
|
+
# rubocop:enable Metrics/ParameterLists
|
|
42
|
+
|
|
43
|
+
def show_icon?
|
|
44
|
+
variant != :default
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def wrap_in_viewport?
|
|
48
|
+
!position.nil?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def needs_stimulus?
|
|
52
|
+
dismissible || duration_ms_positive?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def duration_ms_positive?
|
|
56
|
+
!duration_ms.nil? && duration_ms.positive?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def viewport_computed_attrs
|
|
62
|
+
modifier = "ui-toast-viewport--#{position.to_s.tr("_", "-")}"
|
|
63
|
+
defaults = {
|
|
64
|
+
class: class_names("ui-toast-viewport", modifier),
|
|
65
|
+
role: "region",
|
|
66
|
+
"aria-label": "Notifications",
|
|
67
|
+
"aria-live": "polite"
|
|
68
|
+
}
|
|
69
|
+
merge_attrs(defaults, viewport_html_attrs)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def normalize_position(value)
|
|
73
|
+
sym = value.to_sym
|
|
74
|
+
POSITIONS.include?(sym) ? sym : :bottom_right
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def computed_attrs
|
|
78
|
+
attrs = merge_attrs(toast_root_defaults, html_attrs).dup
|
|
79
|
+
attrs[:data] = merged_stimulus_data(attrs[:data]) if needs_stimulus?
|
|
80
|
+
attrs
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def toast_root_defaults
|
|
84
|
+
destructive = variant == :destructive
|
|
85
|
+
{
|
|
86
|
+
class: class_names("ui-toast", "ui-toast--#{variant}"),
|
|
87
|
+
role: destructive ? "alert" : "status",
|
|
88
|
+
"aria-live": destructive ? "assertive" : "polite",
|
|
89
|
+
"aria-atomic": "true"
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def merged_stimulus_data(existing)
|
|
94
|
+
d = (existing || {}).dup
|
|
95
|
+
ctrls = [d[:controller], "ui--toast"].compact.join(" ").split(/\s+/).uniq.join(" ")
|
|
96
|
+
d[:controller] = ctrls
|
|
97
|
+
d[:"ui--toast-duration-ms-value"] = duration_ms if duration_ms_positive?
|
|
98
|
+
d
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
description: >-
|
|
2
|
+
Short, dismissible notifications with optional auto-dismiss. Use the position keyword argument
|
|
3
|
+
for a fixed corner or center, or wrap several in .ui-toast-viewport. Stimulus ui--toast handles dismiss and duration.
|
|
4
|
+
|
|
5
|
+
sections:
|
|
6
|
+
- title: Variants
|
|
7
|
+
examples:
|
|
8
|
+
- title: Default
|
|
9
|
+
code: |
|
|
10
|
+
<div class="ui-toast-viewport ui-toast-viewport--inline">
|
|
11
|
+
<%= render Ui::Toast::Component.new do |t| %>
|
|
12
|
+
<% t.with_title { "Saved" } %>
|
|
13
|
+
<% t.with_description { "Your changes were stored." } %>
|
|
14
|
+
<% end %>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
- title: Success
|
|
18
|
+
code: |
|
|
19
|
+
<div class="ui-toast-viewport ui-toast-viewport--inline">
|
|
20
|
+
<%= render Ui::Toast::Component.new(variant: :success) do |t| %>
|
|
21
|
+
<% t.with_title { "Profile updated" } %>
|
|
22
|
+
<% t.with_description { "We synced your settings." } %>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
- title: Warning
|
|
27
|
+
code: |
|
|
28
|
+
<div class="ui-toast-viewport ui-toast-viewport--inline">
|
|
29
|
+
<%= render Ui::Toast::Component.new(variant: :warning) do |t| %>
|
|
30
|
+
<% t.with_title { "Rate limit" } %>
|
|
31
|
+
<% t.with_description { "Try again in a few minutes." } %>
|
|
32
|
+
<% end %>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
- title: Destructive
|
|
36
|
+
code: |
|
|
37
|
+
<div class="ui-toast-viewport ui-toast-viewport--inline">
|
|
38
|
+
<%= render Ui::Toast::Component.new(variant: :destructive) do |t| %>
|
|
39
|
+
<% t.with_title { "Payment failed" } %>
|
|
40
|
+
<% t.with_description { "Check your card details and retry." } %>
|
|
41
|
+
<% end %>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
- title: Behavior
|
|
45
|
+
examples:
|
|
46
|
+
- title: Without dismiss button
|
|
47
|
+
code: |
|
|
48
|
+
<div class="ui-toast-viewport ui-toast-viewport--inline">
|
|
49
|
+
<%= render Ui::Toast::Component.new(dismissible: false) do |t| %>
|
|
50
|
+
<% t.with_description { "This toast stays until the page changes." } %>
|
|
51
|
+
<% end %>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
- title: Auto-dismiss (5s) — requires ui--toast Stimulus
|
|
55
|
+
code: |
|
|
56
|
+
<div class="ui-toast-viewport ui-toast-viewport--inline">
|
|
57
|
+
<%= render Ui::Toast::Component.new(duration_ms: 5000, dismissible: true) do |t| %>
|
|
58
|
+
<% t.with_title { "Sent" } %>
|
|
59
|
+
<% t.with_description { "This message removes itself after five seconds." } %>
|
|
60
|
+
<% end %>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
- title: Message only
|
|
64
|
+
code: |
|
|
65
|
+
<div class="ui-toast-viewport ui-toast-viewport--inline">
|
|
66
|
+
<%= render Ui::Toast::Component.new do |t| %>
|
|
67
|
+
<% t.with_description { "Copied to clipboard." } %>
|
|
68
|
+
<% end %>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
- title: Viewport positions
|
|
72
|
+
examples:
|
|
73
|
+
- title: Top center
|
|
74
|
+
code: |
|
|
75
|
+
<%= render Ui::Toast::Component.new(variant: :success, position: :top_center, viewport_html_attrs: { class: "ui-toast-viewport--inline" }) do |t| %>
|
|
76
|
+
<% t.with_title { "Top center" } %>
|
|
77
|
+
<% t.with_description { "position: :top_center wraps a fixed viewport." } %>
|
|
78
|
+
<% end %>
|
|
79
|
+
|
|
80
|
+
- title: Bottom left
|
|
81
|
+
code: |
|
|
82
|
+
<%= render Ui::Toast::Component.new(variant: :warning, position: :bottom_left, viewport_html_attrs: { class: "ui-toast-viewport--inline" }) do |t| %>
|
|
83
|
+
<% t.with_title { "Bottom left" } %>
|
|
84
|
+
<% t.with_description { "position: :bottom_left adds the viewport wrapper." } %>
|
|
85
|
+
<% end %>
|
|
86
|
+
|
|
87
|
+
- title: Custom attributes
|
|
88
|
+
examples:
|
|
89
|
+
- title: Extra classes
|
|
90
|
+
code: |
|
|
91
|
+
<div class="ui-toast-viewport ui-toast-viewport--inline">
|
|
92
|
+
<%= render Ui::Toast::Component.new(class: "my-toast", data: { testid: "notice-toast" }) do |t| %>
|
|
93
|
+
<% t.with_title { "Heads up" } %>
|
|
94
|
+
<% t.with_description { "You can pass data-* and class like any component." } %>
|
|
95
|
+
<% end %>
|
|
96
|
+
</div>
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/* ============================================
|
|
2
|
+
* UikitRails — Toast
|
|
3
|
+
*
|
|
4
|
+
* Variants: default, success, destructive, warning
|
|
5
|
+
* Stack with .ui-toast-viewport (fixed bottom-right by default).
|
|
6
|
+
* Positions: .ui-toast-viewport--top-left | top-center | top-right |
|
|
7
|
+
* bottom-left | bottom-center | bottom-right
|
|
8
|
+
* Inline: .ui-toast-viewport--inline (flow layout in page content, not fixed)
|
|
9
|
+
* ============================================ */
|
|
10
|
+
|
|
11
|
+
.ui-toast-viewport {
|
|
12
|
+
position: fixed;
|
|
13
|
+
z-index: 100;
|
|
14
|
+
display: flex;
|
|
15
|
+
gap: 0.5rem;
|
|
16
|
+
width: min(100% - 2rem, 22rem);
|
|
17
|
+
max-height: min(70vh, 24rem);
|
|
18
|
+
overflow-y: auto;
|
|
19
|
+
overflow-x: hidden;
|
|
20
|
+
pointer-events: none;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* Default anchor: bottom-right */
|
|
24
|
+
.ui-toast-viewport:not([class*="ui-toast-viewport--"]) {
|
|
25
|
+
right: 1rem;
|
|
26
|
+
bottom: 1rem;
|
|
27
|
+
flex-direction: column-reverse;
|
|
28
|
+
justify-content: flex-end;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.ui-toast-viewport--top-left {
|
|
32
|
+
top: 1rem;
|
|
33
|
+
left: 1rem;
|
|
34
|
+
right: auto;
|
|
35
|
+
bottom: auto;
|
|
36
|
+
flex-direction: column;
|
|
37
|
+
justify-content: flex-start;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.ui-toast-viewport--top-center {
|
|
41
|
+
top: 1rem;
|
|
42
|
+
left: 50%;
|
|
43
|
+
right: auto;
|
|
44
|
+
bottom: auto;
|
|
45
|
+
transform: translateX(-50%);
|
|
46
|
+
flex-direction: column;
|
|
47
|
+
justify-content: flex-start;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.ui-toast-viewport--top-right {
|
|
51
|
+
top: 1rem;
|
|
52
|
+
right: 1rem;
|
|
53
|
+
left: auto;
|
|
54
|
+
bottom: auto;
|
|
55
|
+
flex-direction: column;
|
|
56
|
+
justify-content: flex-start;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.ui-toast-viewport--bottom-left {
|
|
60
|
+
bottom: 1rem;
|
|
61
|
+
left: 1rem;
|
|
62
|
+
right: auto;
|
|
63
|
+
top: auto;
|
|
64
|
+
flex-direction: column-reverse;
|
|
65
|
+
justify-content: flex-end;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.ui-toast-viewport--bottom-center {
|
|
69
|
+
bottom: 1rem;
|
|
70
|
+
left: 50%;
|
|
71
|
+
right: auto;
|
|
72
|
+
top: auto;
|
|
73
|
+
transform: translateX(-50%);
|
|
74
|
+
flex-direction: column-reverse;
|
|
75
|
+
justify-content: flex-end;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.ui-toast-viewport--bottom-right {
|
|
79
|
+
bottom: 1rem;
|
|
80
|
+
right: 1rem;
|
|
81
|
+
left: auto;
|
|
82
|
+
top: auto;
|
|
83
|
+
flex-direction: column-reverse;
|
|
84
|
+
justify-content: flex-end;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* Flow layout in a normal column (styleguide, docs) — disables fixed anchoring */
|
|
88
|
+
.ui-toast-viewport.ui-toast-viewport--inline {
|
|
89
|
+
position: relative;
|
|
90
|
+
inset: auto;
|
|
91
|
+
left: auto;
|
|
92
|
+
right: auto;
|
|
93
|
+
top: auto;
|
|
94
|
+
bottom: auto;
|
|
95
|
+
transform: none;
|
|
96
|
+
width: 100%;
|
|
97
|
+
max-height: none;
|
|
98
|
+
pointer-events: auto;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.ui-toast-viewport .ui-toast {
|
|
102
|
+
pointer-events: auto;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.ui-toast {
|
|
106
|
+
display: flex;
|
|
107
|
+
align-items: flex-start;
|
|
108
|
+
gap: 0.75rem;
|
|
109
|
+
width: 100%;
|
|
110
|
+
padding: 0.875rem 1rem;
|
|
111
|
+
font-family: var(--ui-font-family);
|
|
112
|
+
font-size: var(--ui-font-size-sm);
|
|
113
|
+
line-height: 1.45;
|
|
114
|
+
color: var(--ui-foreground);
|
|
115
|
+
background-color: var(--ui-background);
|
|
116
|
+
border: 1px solid var(--ui-border);
|
|
117
|
+
border-radius: var(--ui-radius);
|
|
118
|
+
box-shadow: 0 10px 15px -3px rgb(15 23 42 / 0.08), 0 4px 6px -4px rgb(15 23 42 / 0.06);
|
|
119
|
+
transition:
|
|
120
|
+
opacity 0.2s ease,
|
|
121
|
+
transform 0.2s ease;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.ui-toast--leaving {
|
|
125
|
+
opacity: 0;
|
|
126
|
+
transform: translateY(0.5rem) scale(0.98);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.ui-toast-viewport--top-left .ui-toast--leaving,
|
|
130
|
+
.ui-toast-viewport--top-center .ui-toast--leaving,
|
|
131
|
+
.ui-toast-viewport--top-right .ui-toast--leaving {
|
|
132
|
+
transform: translateY(-0.5rem) scale(0.98);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.ui-toast__icon {
|
|
136
|
+
flex-shrink: 0;
|
|
137
|
+
margin-top: 0.05rem;
|
|
138
|
+
color: var(--ui-muted-foreground);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.ui-toast__main {
|
|
142
|
+
flex: 1;
|
|
143
|
+
min-width: 0;
|
|
144
|
+
display: flex;
|
|
145
|
+
flex-direction: column;
|
|
146
|
+
gap: 0.2rem;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.ui-toast__title {
|
|
150
|
+
font-weight: 600;
|
|
151
|
+
letter-spacing: -0.01em;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.ui-toast__description {
|
|
155
|
+
color: var(--ui-muted-foreground);
|
|
156
|
+
font-size: var(--ui-font-size-sm);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.ui-toast__close {
|
|
160
|
+
flex-shrink: 0;
|
|
161
|
+
display: inline-flex;
|
|
162
|
+
align-items: center;
|
|
163
|
+
justify-content: center;
|
|
164
|
+
width: 1.75rem;
|
|
165
|
+
height: 1.75rem;
|
|
166
|
+
margin: -0.2rem -0.35rem -0.2rem 0;
|
|
167
|
+
padding: 0;
|
|
168
|
+
border: none;
|
|
169
|
+
border-radius: calc(var(--ui-radius) - 2px);
|
|
170
|
+
background: transparent;
|
|
171
|
+
color: var(--ui-muted-foreground);
|
|
172
|
+
cursor: pointer;
|
|
173
|
+
transition:
|
|
174
|
+
background var(--ui-transition-speed) ease,
|
|
175
|
+
color var(--ui-transition-speed) ease;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.ui-toast__close:hover {
|
|
179
|
+
background: var(--ui-muted);
|
|
180
|
+
color: var(--ui-foreground);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.ui-toast__close:focus-visible {
|
|
184
|
+
outline: 2px solid var(--ui-ring);
|
|
185
|
+
outline-offset: 2px;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* --- Variants --- */
|
|
189
|
+
|
|
190
|
+
.ui-toast--success {
|
|
191
|
+
border-left: 4px solid #22c55e;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.ui-toast--success .ui-toast__icon {
|
|
195
|
+
color: #16a34a;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.ui-toast--warning {
|
|
199
|
+
border-left: 4px solid #f59e0b;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.ui-toast--warning .ui-toast__icon {
|
|
203
|
+
color: #d97706;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.ui-toast--destructive {
|
|
207
|
+
border-color: var(--ui-border);
|
|
208
|
+
border-left: 4px solid var(--ui-destructive);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.ui-toast--destructive .ui-toast__icon {
|
|
212
|
+
color: var(--ui-destructive);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.ui-toast--destructive .ui-toast__description {
|
|
216
|
+
color: var(--ui-foreground);
|
|
217
|
+
opacity: 0.88;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.dark .ui-toast {
|
|
221
|
+
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.35), 0 4px 6px -4px rgb(0 0 0 / 0.25);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.dark .ui-toast--success .ui-toast__icon {
|
|
225
|
+
color: #4ade80;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.dark .ui-toast--warning .ui-toast__icon {
|
|
229
|
+
color: #fbbf24;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
@media (prefers-reduced-motion: reduce) {
|
|
233
|
+
.ui-toast {
|
|
234
|
+
transition: none;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.ui-toast--leaving {
|
|
238
|
+
opacity: 0;
|
|
239
|
+
transform: none;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static values = { durationMs: Number }
|
|
5
|
+
|
|
6
|
+
connect() {
|
|
7
|
+
if (this.hasDurationMsValue && this.durationMsValue > 0) {
|
|
8
|
+
this.timeoutId = window.setTimeout(() => this.dismiss(), this.durationMsValue)
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
disconnect() {
|
|
13
|
+
if (this.timeoutId) {
|
|
14
|
+
window.clearTimeout(this.timeoutId)
|
|
15
|
+
this.timeoutId = null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
dismiss() {
|
|
20
|
+
if (this.timeoutId) {
|
|
21
|
+
window.clearTimeout(this.timeoutId)
|
|
22
|
+
this.timeoutId = null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const el = this.element
|
|
26
|
+
el.classList.add("ui-toast--leaving")
|
|
27
|
+
|
|
28
|
+
let done = false
|
|
29
|
+
const finish = () => {
|
|
30
|
+
if (done) return
|
|
31
|
+
done = true
|
|
32
|
+
el.remove()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
el.addEventListener("transitionend", finish, { once: true })
|
|
36
|
+
window.setTimeout(finish, 320)
|
|
37
|
+
}
|
|
38
|
+
}
|
data/lib/uikit_rails/version.rb
CHANGED
data/lib/uikit_rails.rb
CHANGED
data/test_app/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: ..
|
|
3
3
|
specs:
|
|
4
|
-
uikit_rails (0.1.
|
|
4
|
+
uikit_rails (0.1.3)
|
|
5
5
|
railties (>= 7.0)
|
|
6
6
|
view_component (>= 3.0)
|
|
7
7
|
|
|
@@ -570,7 +570,7 @@ CHECKSUMS
|
|
|
570
570
|
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
|
|
571
571
|
turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f
|
|
572
572
|
tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
|
|
573
|
-
uikit_rails (0.1.
|
|
573
|
+
uikit_rails (0.1.3)
|
|
574
574
|
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
|
|
575
575
|
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
|
|
576
576
|
uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/* ============================================
|
|
2
|
+
* UikitRails — Toast
|
|
3
|
+
*
|
|
4
|
+
* Variants: default, success, destructive, warning
|
|
5
|
+
* Stack with .ui-toast-viewport (fixed bottom-right by default).
|
|
6
|
+
* Positions: .ui-toast-viewport--top-left | top-center | top-right |
|
|
7
|
+
* bottom-left | bottom-center | bottom-right
|
|
8
|
+
* Inline: .ui-toast-viewport--inline (flow layout in page content, not fixed)
|
|
9
|
+
* ============================================ */
|
|
10
|
+
|
|
11
|
+
.ui-toast-viewport {
|
|
12
|
+
position: fixed;
|
|
13
|
+
z-index: 100;
|
|
14
|
+
display: flex;
|
|
15
|
+
gap: 0.5rem;
|
|
16
|
+
width: min(100% - 2rem, 22rem);
|
|
17
|
+
max-height: min(70vh, 24rem);
|
|
18
|
+
overflow-y: auto;
|
|
19
|
+
overflow-x: hidden;
|
|
20
|
+
pointer-events: none;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* Default anchor: bottom-right */
|
|
24
|
+
.ui-toast-viewport:not([class*="ui-toast-viewport--"]) {
|
|
25
|
+
right: 1rem;
|
|
26
|
+
bottom: 1rem;
|
|
27
|
+
flex-direction: column-reverse;
|
|
28
|
+
justify-content: flex-end;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.ui-toast-viewport--top-left {
|
|
32
|
+
top: 1rem;
|
|
33
|
+
left: 1rem;
|
|
34
|
+
right: auto;
|
|
35
|
+
bottom: auto;
|
|
36
|
+
flex-direction: column;
|
|
37
|
+
justify-content: flex-start;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.ui-toast-viewport--top-center {
|
|
41
|
+
top: 1rem;
|
|
42
|
+
left: 50%;
|
|
43
|
+
right: auto;
|
|
44
|
+
bottom: auto;
|
|
45
|
+
transform: translateX(-50%);
|
|
46
|
+
flex-direction: column;
|
|
47
|
+
justify-content: flex-start;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.ui-toast-viewport--top-right {
|
|
51
|
+
top: 1rem;
|
|
52
|
+
right: 1rem;
|
|
53
|
+
left: auto;
|
|
54
|
+
bottom: auto;
|
|
55
|
+
flex-direction: column;
|
|
56
|
+
justify-content: flex-start;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.ui-toast-viewport--bottom-left {
|
|
60
|
+
bottom: 1rem;
|
|
61
|
+
left: 1rem;
|
|
62
|
+
right: auto;
|
|
63
|
+
top: auto;
|
|
64
|
+
flex-direction: column-reverse;
|
|
65
|
+
justify-content: flex-end;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.ui-toast-viewport--bottom-center {
|
|
69
|
+
bottom: 1rem;
|
|
70
|
+
left: 50%;
|
|
71
|
+
right: auto;
|
|
72
|
+
top: auto;
|
|
73
|
+
transform: translateX(-50%);
|
|
74
|
+
flex-direction: column-reverse;
|
|
75
|
+
justify-content: flex-end;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.ui-toast-viewport--bottom-right {
|
|
79
|
+
bottom: 1rem;
|
|
80
|
+
right: 1rem;
|
|
81
|
+
left: auto;
|
|
82
|
+
top: auto;
|
|
83
|
+
flex-direction: column-reverse;
|
|
84
|
+
justify-content: flex-end;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* Flow layout in a normal column (styleguide, docs) — disables fixed anchoring */
|
|
88
|
+
.ui-toast-viewport.ui-toast-viewport--inline {
|
|
89
|
+
position: relative;
|
|
90
|
+
inset: auto;
|
|
91
|
+
left: auto;
|
|
92
|
+
right: auto;
|
|
93
|
+
top: auto;
|
|
94
|
+
bottom: auto;
|
|
95
|
+
transform: none;
|
|
96
|
+
width: 100%;
|
|
97
|
+
max-height: none;
|
|
98
|
+
pointer-events: auto;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.ui-toast-viewport .ui-toast {
|
|
102
|
+
pointer-events: auto;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.ui-toast {
|
|
106
|
+
display: flex;
|
|
107
|
+
align-items: flex-start;
|
|
108
|
+
gap: 0.75rem;
|
|
109
|
+
width: 100%;
|
|
110
|
+
padding: 0.875rem 1rem;
|
|
111
|
+
font-family: var(--ui-font-family);
|
|
112
|
+
font-size: var(--ui-font-size-sm);
|
|
113
|
+
line-height: 1.45;
|
|
114
|
+
color: var(--ui-foreground);
|
|
115
|
+
background-color: var(--ui-background);
|
|
116
|
+
border: 1px solid var(--ui-border);
|
|
117
|
+
border-radius: var(--ui-radius);
|
|
118
|
+
box-shadow: 0 10px 15px -3px rgb(15 23 42 / 0.08), 0 4px 6px -4px rgb(15 23 42 / 0.06);
|
|
119
|
+
transition:
|
|
120
|
+
opacity 0.2s ease,
|
|
121
|
+
transform 0.2s ease;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.ui-toast--leaving {
|
|
125
|
+
opacity: 0;
|
|
126
|
+
transform: translateY(0.5rem) scale(0.98);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.ui-toast-viewport--top-left .ui-toast--leaving,
|
|
130
|
+
.ui-toast-viewport--top-center .ui-toast--leaving,
|
|
131
|
+
.ui-toast-viewport--top-right .ui-toast--leaving {
|
|
132
|
+
transform: translateY(-0.5rem) scale(0.98);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.ui-toast__icon {
|
|
136
|
+
flex-shrink: 0;
|
|
137
|
+
margin-top: 0.05rem;
|
|
138
|
+
color: var(--ui-muted-foreground);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.ui-toast__main {
|
|
142
|
+
flex: 1;
|
|
143
|
+
min-width: 0;
|
|
144
|
+
display: flex;
|
|
145
|
+
flex-direction: column;
|
|
146
|
+
gap: 0.2rem;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.ui-toast__title {
|
|
150
|
+
font-weight: 600;
|
|
151
|
+
letter-spacing: -0.01em;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.ui-toast__description {
|
|
155
|
+
color: var(--ui-muted-foreground);
|
|
156
|
+
font-size: var(--ui-font-size-sm);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.ui-toast__close {
|
|
160
|
+
flex-shrink: 0;
|
|
161
|
+
display: inline-flex;
|
|
162
|
+
align-items: center;
|
|
163
|
+
justify-content: center;
|
|
164
|
+
width: 1.75rem;
|
|
165
|
+
height: 1.75rem;
|
|
166
|
+
margin: -0.2rem -0.35rem -0.2rem 0;
|
|
167
|
+
padding: 0;
|
|
168
|
+
border: none;
|
|
169
|
+
border-radius: calc(var(--ui-radius) - 2px);
|
|
170
|
+
background: transparent;
|
|
171
|
+
color: var(--ui-muted-foreground);
|
|
172
|
+
cursor: pointer;
|
|
173
|
+
transition:
|
|
174
|
+
background var(--ui-transition-speed) ease,
|
|
175
|
+
color var(--ui-transition-speed) ease;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.ui-toast__close:hover {
|
|
179
|
+
background: var(--ui-muted);
|
|
180
|
+
color: var(--ui-foreground);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.ui-toast__close:focus-visible {
|
|
184
|
+
outline: 2px solid var(--ui-ring);
|
|
185
|
+
outline-offset: 2px;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* --- Variants --- */
|
|
189
|
+
|
|
190
|
+
.ui-toast--success {
|
|
191
|
+
border-left: 4px solid #22c55e;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.ui-toast--success .ui-toast__icon {
|
|
195
|
+
color: #16a34a;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.ui-toast--warning {
|
|
199
|
+
border-left: 4px solid #f59e0b;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.ui-toast--warning .ui-toast__icon {
|
|
203
|
+
color: #d97706;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.ui-toast--destructive {
|
|
207
|
+
border-color: var(--ui-border);
|
|
208
|
+
border-left: 4px solid var(--ui-destructive);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.ui-toast--destructive .ui-toast__icon {
|
|
212
|
+
color: var(--ui-destructive);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.ui-toast--destructive .ui-toast__description {
|
|
216
|
+
color: var(--ui-foreground);
|
|
217
|
+
opacity: 0.88;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.dark .ui-toast {
|
|
221
|
+
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.35), 0 4px 6px -4px rgb(0 0 0 / 0.25);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.dark .ui-toast--success .ui-toast__icon {
|
|
225
|
+
color: #4ade80;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.dark .ui-toast--warning .ui-toast__icon {
|
|
229
|
+
color: #fbbf24;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
@media (prefers-reduced-motion: reduce) {
|
|
233
|
+
.ui-toast {
|
|
234
|
+
transition: none;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.ui-toast--leaving {
|
|
238
|
+
opacity: 0;
|
|
239
|
+
transform: none;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Basic toast:
|
|
2
|
+
|
|
3
|
+
<%= render Ui::Toast::Component.new do |toast| %>
|
|
4
|
+
<% toast.with_title do %>Done<% end %>
|
|
5
|
+
<% toast.with_description do %>Operation completed.<% end %>
|
|
6
|
+
<% end %>
|
|
7
|
+
|
|
8
|
+
Variants (default, success, destructive, warning):
|
|
9
|
+
|
|
10
|
+
<%= render Ui::Toast::Component.new(variant: :success) do |toast| %>
|
|
11
|
+
<% toast.with_description do %>Saved.<% end %>
|
|
12
|
+
<% end %>
|
|
13
|
+
|
|
14
|
+
Without dismiss button:
|
|
15
|
+
|
|
16
|
+
<%= render Ui::Toast::Component.new(dismissible: false) do |toast| %>
|
|
17
|
+
<% toast.with_description do %>Processing…<% end %>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
With auto-dismiss (duration_ms; requires ui--toast Stimulus):
|
|
21
|
+
|
|
22
|
+
<%= render Ui::Toast::Component.new(duration_ms: 4000) do |toast| %>
|
|
23
|
+
<% toast.with_description do %>Session expires soon.<% end %>
|
|
24
|
+
<% end %>
|
|
25
|
+
|
|
26
|
+
Stack multiple toasts — wrap in a viewport (see toast.css), omit position on each toast:
|
|
27
|
+
|
|
28
|
+
<div class="ui-toast-viewport ui-toast-viewport--bottom-right" aria-live="polite" aria-label="Notifications">
|
|
29
|
+
<%= render Ui::Toast::Component.new(variant: :success) do |toast| %>
|
|
30
|
+
<% toast.with_description do %>First<% end %>
|
|
31
|
+
<% end %>
|
|
32
|
+
<%= render Ui::Toast::Component.new do |toast| %>
|
|
33
|
+
<% toast.with_description do %>Second<% end %>
|
|
34
|
+
<% end %>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
Single toast with fixed position (wraps in .ui-toast-viewport for you):
|
|
38
|
+
|
|
39
|
+
position: :top_left, :top_center, :top_right, :bottom_left, :bottom_center, :bottom_right
|
|
40
|
+
|
|
41
|
+
<%= render Ui::Toast::Component.new(position: :top_center) do |toast| %>
|
|
42
|
+
<% toast.with_description do %>Centered along the top edge.<% end %>
|
|
43
|
+
<% end %>
|
|
44
|
+
|
|
45
|
+
For in-flow previews (not fixed to the window), add ui-toast-viewport--inline on the viewport
|
|
46
|
+
or viewport_html_attrs: { class: "ui-toast-viewport--inline" } when using position:.
|
|
47
|
+
|
|
48
|
+
Slots:
|
|
49
|
+
title — Optional heading
|
|
50
|
+
description — Optional supporting text
|
|
51
|
+
content — Default slot for extra markup
|
|
52
|
+
|
|
53
|
+
Close button uses data-action="click->ui--toast#dismiss" (removes the node after a short animation).
|
|
54
|
+
|
|
55
|
+
With custom attributes:
|
|
56
|
+
|
|
57
|
+
<%= render Ui::Toast::Component.new(class: "extra", data: { testid: "notice" }) do |toast| %>
|
|
58
|
+
<% toast.with_title do %>Heads up<% end %>
|
|
59
|
+
<% end %>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<% toast_inner = capture do %>
|
|
2
|
+
<% if show_icon? %>
|
|
3
|
+
<div class="ui-toast__icon" aria-hidden="true">
|
|
4
|
+
<% case variant
|
|
5
|
+
when :success %>
|
|
6
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
|
7
|
+
<% when :destructive %>
|
|
8
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>
|
|
9
|
+
<% when :warning %>
|
|
10
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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-3Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/></svg>
|
|
11
|
+
<% end %>
|
|
12
|
+
</div>
|
|
13
|
+
<% end %>
|
|
14
|
+
<div class="ui-toast__main">
|
|
15
|
+
<% if title? %>
|
|
16
|
+
<div class="ui-toast__title"><%= title %></div>
|
|
17
|
+
<% end %>
|
|
18
|
+
<% if description? %>
|
|
19
|
+
<div class="ui-toast__description"><%= description %></div>
|
|
20
|
+
<% end %>
|
|
21
|
+
<%= content %>
|
|
22
|
+
</div>
|
|
23
|
+
<% if dismissible %>
|
|
24
|
+
<button type="button" class="ui-toast__close" data-action="click->ui--toast#dismiss" aria-label="Dismiss notification">
|
|
25
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
26
|
+
</button>
|
|
27
|
+
<% end %>
|
|
28
|
+
<% end %>
|
|
29
|
+
|
|
30
|
+
<% if wrap_in_viewport? %>
|
|
31
|
+
<%= content_tag :div, **viewport_computed_attrs do %>
|
|
32
|
+
<%= content_tag :div, **computed_attrs do %>
|
|
33
|
+
<%= toast_inner %>
|
|
34
|
+
<% end %>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% else %>
|
|
37
|
+
<%= content_tag :div, **computed_attrs do %>
|
|
38
|
+
<%= toast_inner %>
|
|
39
|
+
<% end %>
|
|
40
|
+
<% end %>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ui
|
|
4
|
+
module Toast
|
|
5
|
+
# Brief notification with optional title, description, dismiss control, auto-dismiss,
|
|
6
|
+
# and optional fixed viewport wrapper (six screen positions).
|
|
7
|
+
class Component < Ui::BaseComponent
|
|
8
|
+
VARIANTS = %i[default success destructive warning].freeze
|
|
9
|
+
|
|
10
|
+
POSITIONS = %i[
|
|
11
|
+
top_left
|
|
12
|
+
top_center
|
|
13
|
+
top_right
|
|
14
|
+
bottom_left
|
|
15
|
+
bottom_center
|
|
16
|
+
bottom_right
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
renders_one :title
|
|
20
|
+
renders_one :description
|
|
21
|
+
|
|
22
|
+
attr_reader :variant, :dismissible, :duration_ms, :position, :viewport_html_attrs, :html_attrs
|
|
23
|
+
|
|
24
|
+
# rubocop:disable Metrics/ParameterLists -- explicit keywords match ViewComponent / ERB call style
|
|
25
|
+
def initialize(
|
|
26
|
+
variant: :default,
|
|
27
|
+
dismissible: true,
|
|
28
|
+
duration_ms: nil,
|
|
29
|
+
position: nil,
|
|
30
|
+
viewport_html_attrs: {},
|
|
31
|
+
**html_attrs
|
|
32
|
+
)
|
|
33
|
+
@variant = variant.to_sym
|
|
34
|
+
@dismissible = dismissible
|
|
35
|
+
@duration_ms = duration_ms&.to_i
|
|
36
|
+
@position = position.nil? ? nil : normalize_position(position)
|
|
37
|
+
@viewport_html_attrs = viewport_html_attrs
|
|
38
|
+
@html_attrs = html_attrs
|
|
39
|
+
super()
|
|
40
|
+
end
|
|
41
|
+
# rubocop:enable Metrics/ParameterLists
|
|
42
|
+
|
|
43
|
+
def show_icon?
|
|
44
|
+
variant != :default
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def wrap_in_viewport?
|
|
48
|
+
!position.nil?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def needs_stimulus?
|
|
52
|
+
dismissible || duration_ms_positive?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def duration_ms_positive?
|
|
56
|
+
!duration_ms.nil? && duration_ms.positive?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def viewport_computed_attrs
|
|
62
|
+
modifier = "ui-toast-viewport--#{position.to_s.tr("_", "-")}"
|
|
63
|
+
defaults = {
|
|
64
|
+
class: class_names("ui-toast-viewport", modifier),
|
|
65
|
+
role: "region",
|
|
66
|
+
"aria-label": "Notifications",
|
|
67
|
+
"aria-live": "polite"
|
|
68
|
+
}
|
|
69
|
+
merge_attrs(defaults, viewport_html_attrs)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def normalize_position(value)
|
|
73
|
+
sym = value.to_sym
|
|
74
|
+
POSITIONS.include?(sym) ? sym : :bottom_right
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def computed_attrs
|
|
78
|
+
attrs = merge_attrs(toast_root_defaults, html_attrs).dup
|
|
79
|
+
attrs[:data] = merged_stimulus_data(attrs[:data]) if needs_stimulus?
|
|
80
|
+
attrs
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def toast_root_defaults
|
|
84
|
+
destructive = variant == :destructive
|
|
85
|
+
{
|
|
86
|
+
class: class_names("ui-toast", "ui-toast--#{variant}"),
|
|
87
|
+
role: destructive ? "alert" : "status",
|
|
88
|
+
"aria-live": destructive ? "assertive" : "polite",
|
|
89
|
+
"aria-atomic": "true"
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def merged_stimulus_data(existing)
|
|
94
|
+
d = (existing || {}).dup
|
|
95
|
+
ctrls = [d[:controller], "ui--toast"].compact.join(" ").split(/\s+/).uniq.join(" ")
|
|
96
|
+
d[:controller] = ctrls
|
|
97
|
+
d[:"ui--toast-duration-ms-value"] = duration_ms if duration_ms_positive?
|
|
98
|
+
d
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
description: >-
|
|
2
|
+
Short, dismissible notifications with optional auto-dismiss. Use the position keyword argument
|
|
3
|
+
for a fixed corner or center, or wrap several in .ui-toast-viewport. Stimulus ui--toast handles dismiss and duration.
|
|
4
|
+
|
|
5
|
+
sections:
|
|
6
|
+
- title: Variants
|
|
7
|
+
examples:
|
|
8
|
+
- title: Default
|
|
9
|
+
code: |
|
|
10
|
+
<div class="ui-toast-viewport ui-toast-viewport--inline">
|
|
11
|
+
<%= render Ui::Toast::Component.new do |t| %>
|
|
12
|
+
<% t.with_title { "Saved" } %>
|
|
13
|
+
<% t.with_description { "Your changes were stored." } %>
|
|
14
|
+
<% end %>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
- title: Success
|
|
18
|
+
code: |
|
|
19
|
+
<div class="ui-toast-viewport ui-toast-viewport--inline">
|
|
20
|
+
<%= render Ui::Toast::Component.new(variant: :success) do |t| %>
|
|
21
|
+
<% t.with_title { "Profile updated" } %>
|
|
22
|
+
<% t.with_description { "We synced your settings." } %>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
- title: Warning
|
|
27
|
+
code: |
|
|
28
|
+
<div class="ui-toast-viewport ui-toast-viewport--inline">
|
|
29
|
+
<%= render Ui::Toast::Component.new(variant: :warning) do |t| %>
|
|
30
|
+
<% t.with_title { "Rate limit" } %>
|
|
31
|
+
<% t.with_description { "Try again in a few minutes." } %>
|
|
32
|
+
<% end %>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
- title: Destructive
|
|
36
|
+
code: |
|
|
37
|
+
<div class="ui-toast-viewport ui-toast-viewport--inline">
|
|
38
|
+
<%= render Ui::Toast::Component.new(variant: :destructive) do |t| %>
|
|
39
|
+
<% t.with_title { "Payment failed" } %>
|
|
40
|
+
<% t.with_description { "Check your card details and retry." } %>
|
|
41
|
+
<% end %>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
- title: Behavior
|
|
45
|
+
examples:
|
|
46
|
+
- title: Without dismiss button
|
|
47
|
+
code: |
|
|
48
|
+
<div class="ui-toast-viewport ui-toast-viewport--inline">
|
|
49
|
+
<%= render Ui::Toast::Component.new(dismissible: false) do |t| %>
|
|
50
|
+
<% t.with_description { "This toast stays until the page changes." } %>
|
|
51
|
+
<% end %>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
- title: Auto-dismiss (5s) — requires ui--toast Stimulus
|
|
55
|
+
code: |
|
|
56
|
+
<div class="ui-toast-viewport ui-toast-viewport--inline">
|
|
57
|
+
<%= render Ui::Toast::Component.new(duration_ms: 5000, dismissible: true) do |t| %>
|
|
58
|
+
<% t.with_title { "Sent" } %>
|
|
59
|
+
<% t.with_description { "This message removes itself after five seconds." } %>
|
|
60
|
+
<% end %>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
- title: Message only
|
|
64
|
+
code: |
|
|
65
|
+
<div class="ui-toast-viewport ui-toast-viewport--inline">
|
|
66
|
+
<%= render Ui::Toast::Component.new do |t| %>
|
|
67
|
+
<% t.with_description { "Copied to clipboard." } %>
|
|
68
|
+
<% end %>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
- title: Viewport positions
|
|
72
|
+
examples:
|
|
73
|
+
- title: Top center
|
|
74
|
+
code: |
|
|
75
|
+
<%= render Ui::Toast::Component.new(variant: :success, position: :top_center, viewport_html_attrs: { class: "ui-toast-viewport--inline" }) do |t| %>
|
|
76
|
+
<% t.with_title { "Top center" } %>
|
|
77
|
+
<% t.with_description { "position: :top_center wraps a fixed viewport." } %>
|
|
78
|
+
<% end %>
|
|
79
|
+
|
|
80
|
+
- title: Bottom left
|
|
81
|
+
code: |
|
|
82
|
+
<%= render Ui::Toast::Component.new(variant: :warning, position: :bottom_left, viewport_html_attrs: { class: "ui-toast-viewport--inline" }) do |t| %>
|
|
83
|
+
<% t.with_title { "Bottom left" } %>
|
|
84
|
+
<% t.with_description { "position: :bottom_left adds the viewport wrapper." } %>
|
|
85
|
+
<% end %>
|
|
86
|
+
|
|
87
|
+
- title: Custom attributes
|
|
88
|
+
examples:
|
|
89
|
+
- title: Extra classes
|
|
90
|
+
code: |
|
|
91
|
+
<div class="ui-toast-viewport ui-toast-viewport--inline">
|
|
92
|
+
<%= render Ui::Toast::Component.new(class: "my-toast", data: { testid: "notice-toast" }) do |t| %>
|
|
93
|
+
<% t.with_title { "Heads up" } %>
|
|
94
|
+
<% t.with_description { "You can pass data-* and class like any component." } %>
|
|
95
|
+
<% end %>
|
|
96
|
+
</div>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static values = { durationMs: Number }
|
|
5
|
+
|
|
6
|
+
connect() {
|
|
7
|
+
if (this.hasDurationMsValue && this.durationMsValue > 0) {
|
|
8
|
+
this.timeoutId = window.setTimeout(() => this.dismiss(), this.durationMsValue)
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
disconnect() {
|
|
13
|
+
if (this.timeoutId) {
|
|
14
|
+
window.clearTimeout(this.timeoutId)
|
|
15
|
+
this.timeoutId = null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
dismiss() {
|
|
20
|
+
if (this.timeoutId) {
|
|
21
|
+
window.clearTimeout(this.timeoutId)
|
|
22
|
+
this.timeoutId = null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const el = this.element
|
|
26
|
+
el.classList.add("ui-toast--leaving")
|
|
27
|
+
|
|
28
|
+
let done = false
|
|
29
|
+
const finish = () => {
|
|
30
|
+
if (done) return
|
|
31
|
+
done = true
|
|
32
|
+
el.remove()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
el.addEventListener("transitionend", finish, { once: true })
|
|
36
|
+
window.setTimeout(finish, 320)
|
|
37
|
+
}
|
|
38
|
+
}
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: uikit_rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Manh Kha
|
|
@@ -180,6 +180,11 @@ files:
|
|
|
180
180
|
- lib/uikit_rails/templates/components/textarea/component.rb
|
|
181
181
|
- lib/uikit_rails/templates/components/textarea/preview.yml
|
|
182
182
|
- lib/uikit_rails/templates/components/textarea/textarea.css
|
|
183
|
+
- lib/uikit_rails/templates/components/toast/USAGE
|
|
184
|
+
- lib/uikit_rails/templates/components/toast/component.html.erb
|
|
185
|
+
- lib/uikit_rails/templates/components/toast/component.rb
|
|
186
|
+
- lib/uikit_rails/templates/components/toast/preview.yml
|
|
187
|
+
- lib/uikit_rails/templates/components/toast/toast.css
|
|
183
188
|
- lib/uikit_rails/templates/components/toggle/USAGE
|
|
184
189
|
- lib/uikit_rails/templates/components/toggle/component.rb
|
|
185
190
|
- lib/uikit_rails/templates/components/toggle/preview.yml
|
|
@@ -197,6 +202,7 @@ files:
|
|
|
197
202
|
- lib/uikit_rails/templates/stimulus/popover_controller.js
|
|
198
203
|
- lib/uikit_rails/templates/stimulus/sheet_controller.js
|
|
199
204
|
- lib/uikit_rails/templates/stimulus/tabs_controller.js
|
|
205
|
+
- lib/uikit_rails/templates/stimulus/toast_controller.js
|
|
200
206
|
- lib/uikit_rails/templates/stimulus/tooltip_controller.js
|
|
201
207
|
- lib/uikit_rails/version.rb
|
|
202
208
|
- sig/uikit_rails.rbs
|
|
@@ -250,6 +256,7 @@ files:
|
|
|
250
256
|
- test_app/app/assets/stylesheets/ui/table.css
|
|
251
257
|
- test_app/app/assets/stylesheets/ui/tabs.css
|
|
252
258
|
- test_app/app/assets/stylesheets/ui/textarea.css
|
|
259
|
+
- test_app/app/assets/stylesheets/ui/toast.css
|
|
253
260
|
- test_app/app/assets/stylesheets/ui/toggle.css
|
|
254
261
|
- test_app/app/assets/stylesheets/ui/tooltip.css
|
|
255
262
|
- test_app/app/assets/stylesheets/uikit_rails.css
|
|
@@ -327,6 +334,10 @@ files:
|
|
|
327
334
|
- test_app/app/components/ui/tabs/preview.yml
|
|
328
335
|
- test_app/app/components/ui/textarea/component.rb
|
|
329
336
|
- test_app/app/components/ui/textarea/preview.yml
|
|
337
|
+
- test_app/app/components/ui/toast/USAGE
|
|
338
|
+
- test_app/app/components/ui/toast/component.html.erb
|
|
339
|
+
- test_app/app/components/ui/toast/component.rb
|
|
340
|
+
- test_app/app/components/ui/toast/preview.yml
|
|
330
341
|
- test_app/app/components/ui/toggle/component.rb
|
|
331
342
|
- test_app/app/components/ui/toggle/preview.yml
|
|
332
343
|
- test_app/app/components/ui/tooltip/component.html.erb
|
|
@@ -347,6 +358,7 @@ files:
|
|
|
347
358
|
- test_app/app/javascript/controllers/ui/popover_controller.js
|
|
348
359
|
- test_app/app/javascript/controllers/ui/sheet_controller.js
|
|
349
360
|
- test_app/app/javascript/controllers/ui/tabs_controller.js
|
|
361
|
+
- test_app/app/javascript/controllers/ui/toast_controller.js
|
|
350
362
|
- test_app/app/javascript/controllers/ui/tooltip_controller.js
|
|
351
363
|
- test_app/app/jobs/application_job.rb
|
|
352
364
|
- test_app/app/mailers/application_mailer.rb
|