ruby_ui 1.3.0 → 1.6.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/README.md +6 -0
- data/lib/generators/ruby_ui/component/all_generator.rb +6 -4
- data/lib/generators/ruby_ui/component_generator.rb +51 -31
- data/lib/generators/ruby_ui/dependencies.yml +12 -2
- data/lib/generators/ruby_ui/javascript_utils.rb +0 -13
- data/lib/ruby_ui/accordion/accordion_content.rb +4 -2
- data/lib/ruby_ui/accordion/accordion_controller.js +28 -6
- data/lib/ruby_ui/avatar/avatar_image.rb +5 -1
- data/lib/ruby_ui/bubble/bubble.rb +37 -0
- data/lib/ruby_ui/bubble/bubble_content.rb +23 -0
- data/lib/ruby_ui/bubble/bubble_docs.rb +167 -0
- data/lib/ruby_ui/bubble/bubble_group.rb +18 -0
- data/lib/ruby_ui/bubble/bubble_reactions.rb +38 -0
- data/lib/ruby_ui/combobox/combobox.rb +3 -1
- data/lib/ruby_ui/combobox/combobox_checkbox.rb +8 -1
- data/lib/ruby_ui/combobox/combobox_controller.js +3 -2
- data/lib/ruby_ui/combobox/combobox_trigger.rb +1 -4
- data/lib/ruby_ui/context_menu/context_menu_content.rb +5 -6
- data/lib/ruby_ui/context_menu/context_menu_controller.js +87 -75
- data/lib/ruby_ui/data_table/data_table_column_toggle.rb +8 -4
- data/lib/ruby_ui/data_table/data_table_column_visibility_controller.js +12 -2
- data/lib/ruby_ui/data_table/data_table_docs.rb +1 -1
- data/lib/ruby_ui/dialog/dialog_content.rb +7 -19
- data/lib/ruby_ui/dialog/dialog_controller.js +22 -10
- data/lib/ruby_ui/dropdown_menu/dropdown_menu.rb +0 -1
- data/lib/ruby_ui/dropdown_menu/dropdown_menu_controller.js +5 -1
- data/lib/ruby_ui/dropdown_menu/dropdown_menu_docs.rb +1 -1
- data/lib/ruby_ui/dropdown_menu/dropdown_menu_item.rb +10 -4
- data/lib/ruby_ui/empty/empty.rb +18 -0
- data/lib/ruby_ui/empty/empty_content.rb +18 -0
- data/lib/ruby_ui/empty/empty_description.rb +18 -0
- data/lib/ruby_ui/empty/empty_docs.rb +69 -0
- data/lib/ruby_ui/empty/empty_header.rb +18 -0
- data/lib/ruby_ui/empty/empty_media.rb +31 -0
- data/lib/ruby_ui/empty/empty_title.rb +18 -0
- data/lib/ruby_ui/form/form_docs.rb +89 -0
- data/lib/ruby_ui/hover_card/hover_card_content.rb +5 -6
- data/lib/ruby_ui/hover_card/hover_card_controller.js +113 -76
- data/lib/ruby_ui/input_otp/input_otp.rb +42 -0
- data/lib/ruby_ui/input_otp/input_otp_controller.js +128 -0
- data/lib/ruby_ui/input_otp/input_otp_docs.rb +213 -0
- data/lib/ruby_ui/input_otp/input_otp_group.rb +15 -0
- data/lib/ruby_ui/input_otp/input_otp_separator.rb +39 -0
- data/lib/ruby_ui/input_otp/input_otp_slot.rb +33 -0
- data/lib/ruby_ui/message/message.rb +23 -0
- data/lib/ruby_ui/message/message_avatar.rb +18 -0
- data/lib/ruby_ui/message/message_content.rb +18 -0
- data/lib/ruby_ui/message/message_docs.rb +173 -0
- data/lib/ruby_ui/message/message_footer.rb +18 -0
- data/lib/ruby_ui/message/message_group.rb +18 -0
- data/lib/ruby_ui/message/message_header.rb +18 -0
- data/lib/ruby_ui/message_scroller/message_scroller.rb +18 -0
- data/lib/ruby_ui/message_scroller/message_scroller_button.rb +56 -0
- data/lib/ruby_ui/message_scroller/message_scroller_content.rb +23 -0
- data/lib/ruby_ui/message_scroller/message_scroller_controller.js +335 -0
- data/lib/ruby_ui/message_scroller/message_scroller_docs.rb +208 -0
- data/lib/ruby_ui/message_scroller/message_scroller_item.rb +28 -0
- data/lib/ruby_ui/message_scroller/message_scroller_provider.rb +34 -0
- data/lib/ruby_ui/message_scroller/message_scroller_viewport.rb +22 -0
- data/lib/ruby_ui/native_select/native_select_icon.rb +1 -1
- data/lib/ruby_ui/popover/popover_content.rb +2 -1
- data/lib/ruby_ui/popover/popover_controller.js +95 -32
- data/lib/ruby_ui/table/table_docs.rb +2 -2
- data/lib/ruby_ui/tabs/tabs_docs.rb +1 -1
- data/lib/ruby_ui/tabs/tabs_trigger.rb +10 -4
- data/lib/ruby_ui/toast/toaster_controller.js +24 -8
- data/lib/ruby_ui.rb +1 -1
- metadata +35 -2
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUI
|
|
4
|
+
class EmptyMedia < Base
|
|
5
|
+
VARIANTS = {
|
|
6
|
+
default: "bg-transparent",
|
|
7
|
+
icon: "size-10 rounded-xl bg-muted text-foreground [&_svg:not([class*='size-'])]:size-5"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
def initialize(variant: :default, **attrs)
|
|
11
|
+
@variant = variant
|
|
12
|
+
super(**attrs)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def view_template(&)
|
|
16
|
+
div(**attrs, &)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def default_attrs
|
|
22
|
+
{
|
|
23
|
+
data: {slot: "empty-icon", variant: @variant},
|
|
24
|
+
class: [
|
|
25
|
+
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
|
26
|
+
VARIANTS[@variant]
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUI
|
|
4
|
+
class EmptyTitle < Base
|
|
5
|
+
def view_template(&)
|
|
6
|
+
div(**attrs, &)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def default_attrs
|
|
12
|
+
{
|
|
13
|
+
data: {slot: "empty-title"},
|
|
14
|
+
class: "text-lg font-medium tracking-tight"
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -170,6 +170,95 @@ class Views::Docs::Form < Views::Base
|
|
|
170
170
|
RUBY
|
|
171
171
|
end
|
|
172
172
|
|
|
173
|
+
Heading(level: 2) { "Rails Integration" }
|
|
174
|
+
|
|
175
|
+
Text do
|
|
176
|
+
plain "RubyUI Form components are plain HTML — they work with any form submission strategy. "
|
|
177
|
+
plain "The recommended approach for Rails apps is to use "
|
|
178
|
+
InlineLink(href: "https://api.rubyonrails.org/classes/ActionView/Helpers/FormHelper.html#method-i-form_with", target: "_blank") { "form_with" }
|
|
179
|
+
plain " to generate the "
|
|
180
|
+
code(class: "font-mono text-sm") { "action" }
|
|
181
|
+
plain " URL and CSRF token, then pass explicit "
|
|
182
|
+
code(class: "font-mono text-sm") { "name" }
|
|
183
|
+
plain " / "
|
|
184
|
+
code(class: "font-mono text-sm") { "id" }
|
|
185
|
+
plain " attributes to each RubyUI input so the browser serialises them correctly. "
|
|
186
|
+
plain "Server-side errors can be surfaced by rendering "
|
|
187
|
+
code(class: "font-mono text-sm") { "FormFieldError" }
|
|
188
|
+
plain " with content from "
|
|
189
|
+
code(class: "font-mono text-sm") { "model.errors.full_messages_for(:attr)" }
|
|
190
|
+
plain "."
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
Heading(level: 3) { "Minimal Rails form" }
|
|
194
|
+
Codeblock(<<~RUBY, syntax: :ruby)
|
|
195
|
+
# In your Phlex view, call form_with via helpers:
|
|
196
|
+
# form_with(url: users_path, method: :post) passes action + CSRF automatically.
|
|
197
|
+
#
|
|
198
|
+
# You can also set action and the CSRF token manually:
|
|
199
|
+
Form(action: helpers.users_path, method: "post", class: "w-2/3 space-y-6") do
|
|
200
|
+
input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token)
|
|
201
|
+
|
|
202
|
+
FormField do
|
|
203
|
+
FormFieldLabel(for: "user_email") { "Email" }
|
|
204
|
+
Input(
|
|
205
|
+
type: "email",
|
|
206
|
+
id: "user_email",
|
|
207
|
+
name: "user[email]",
|
|
208
|
+
placeholder: "you@example.com",
|
|
209
|
+
required: true
|
|
210
|
+
)
|
|
211
|
+
FormFieldError()
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
Button(type: "submit") { "Continue" }
|
|
215
|
+
end
|
|
216
|
+
RUBY
|
|
217
|
+
|
|
218
|
+
Heading(level: 3) { "Devise-style login form" }
|
|
219
|
+
Codeblock(<<~RUBY, syntax: :ruby)
|
|
220
|
+
# Full sign-in form mirroring Devise session[email] / session[password] params.
|
|
221
|
+
# Pass backend errors (e.g. "Invalid email or password") into FormFieldError.
|
|
222
|
+
Form(action: helpers.user_session_path, method: "post", class: "space-y-6") do
|
|
223
|
+
input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token)
|
|
224
|
+
|
|
225
|
+
FormField do
|
|
226
|
+
FormFieldLabel(for: "session_email") { "Email" }
|
|
227
|
+
Input(
|
|
228
|
+
type: "email",
|
|
229
|
+
id: "session_email",
|
|
230
|
+
name: "session[email]",
|
|
231
|
+
placeholder: "you@example.com",
|
|
232
|
+
autocomplete: "email",
|
|
233
|
+
required: true
|
|
234
|
+
)
|
|
235
|
+
FormFieldError { @error_message }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
FormField do
|
|
239
|
+
FormFieldLabel(for: "session_password") { "Password" }
|
|
240
|
+
Input(
|
|
241
|
+
type: "password",
|
|
242
|
+
id: "session_password",
|
|
243
|
+
name: "session[password]",
|
|
244
|
+
autocomplete: "current-password",
|
|
245
|
+
required: true,
|
|
246
|
+
minlength: "8"
|
|
247
|
+
)
|
|
248
|
+
FormFieldError()
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
FormField do
|
|
252
|
+
div(class: "flex items-center gap-2") do
|
|
253
|
+
Checkbox(id: "session_remember_me", name: "session[remember_me]", value: "1")
|
|
254
|
+
FormFieldLabel(for: "session_remember_me") { "Remember me" }
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
Button(type: "submit", class: "w-full") { "Sign in" }
|
|
259
|
+
end
|
|
260
|
+
RUBY
|
|
261
|
+
|
|
173
262
|
render Components::ComponentSetup::Tabs.new(component_name: component)
|
|
174
263
|
|
|
175
264
|
render Docs::ComponentsTable.new(component_files(component))
|
|
@@ -2,10 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module RubyUI
|
|
4
4
|
class HoverCardContent < Base
|
|
5
|
-
def view_template(&
|
|
6
|
-
|
|
7
|
-
div(**attrs, &block)
|
|
8
|
-
end
|
|
5
|
+
def view_template(&)
|
|
6
|
+
div(**attrs, &)
|
|
9
7
|
end
|
|
10
8
|
|
|
11
9
|
private
|
|
@@ -13,9 +11,10 @@ module RubyUI
|
|
|
13
11
|
def default_attrs
|
|
14
12
|
{
|
|
15
13
|
data: {
|
|
16
|
-
|
|
14
|
+
ruby_ui__hover_card_target: "content",
|
|
15
|
+
state: :closed
|
|
17
16
|
},
|
|
18
|
-
class: "z-50 rounded-md border bg-background p-4 text-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
|
17
|
+
class: "hidden absolute z-50 rounded-md border bg-background p-4 text-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
|
19
18
|
}
|
|
20
19
|
end
|
|
21
20
|
end
|
|
@@ -1,106 +1,150 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
computePosition,
|
|
4
|
+
flip,
|
|
5
|
+
shift,
|
|
6
|
+
offset,
|
|
7
|
+
autoUpdate,
|
|
8
|
+
} from "@floating-ui/dom";
|
|
3
9
|
|
|
4
10
|
export default class extends Controller {
|
|
5
11
|
static targets = ["trigger", "content", "menuItem"];
|
|
6
12
|
static values = {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
},
|
|
11
|
-
|
|
12
|
-
matchWidth: {
|
|
13
|
-
type: Boolean,
|
|
14
|
-
default: false,
|
|
15
|
-
}
|
|
16
|
-
}
|
|
13
|
+
open: { type: Boolean, default: false },
|
|
14
|
+
options: { type: Object, default: {} },
|
|
15
|
+
// make content width match the trigger element (true/false)
|
|
16
|
+
matchWidth: { type: Boolean, default: false },
|
|
17
|
+
};
|
|
17
18
|
|
|
18
19
|
connect() {
|
|
19
|
-
this.
|
|
20
|
-
this.
|
|
20
|
+
this.openDelay = this.delayAt(0, 500);
|
|
21
|
+
this.closeDelay = this.delayAt(1, 250);
|
|
22
|
+
this.openTimeout = null;
|
|
23
|
+
this.closeTimeout = null;
|
|
24
|
+
this.cleanup = null;
|
|
21
25
|
this.selectedIndex = -1;
|
|
26
|
+
this.boundHandleKeydown = this.handleKeydown.bind(this);
|
|
27
|
+
this.addEventListeners();
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
disconnect() {
|
|
25
|
-
this.
|
|
31
|
+
this.removeEventListeners();
|
|
32
|
+
this.clearTimers();
|
|
33
|
+
document.removeEventListener("keydown", this.boundHandleKeydown);
|
|
34
|
+
if (this.cleanup) {
|
|
35
|
+
this.cleanup();
|
|
36
|
+
this.cleanup = null;
|
|
37
|
+
}
|
|
26
38
|
}
|
|
27
39
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
this.addEventListeners();
|
|
36
|
-
},
|
|
37
|
-
onHide: () => {
|
|
38
|
-
this.removeEventListeners();
|
|
39
|
-
this.deselectAll();
|
|
40
|
-
},
|
|
41
|
-
popperOptions: {
|
|
42
|
-
modifiers: [
|
|
43
|
-
{
|
|
44
|
-
name: "offset",
|
|
45
|
-
options: {
|
|
46
|
-
offset: [0, 4]
|
|
47
|
-
},
|
|
48
|
-
},
|
|
49
|
-
],
|
|
50
|
-
}
|
|
51
|
-
};
|
|
40
|
+
// Supports the tippy-style `delay` option: a number or a [open, close] tuple.
|
|
41
|
+
delayAt(index, fallback) {
|
|
42
|
+
const delay = this.optionsValue.delay;
|
|
43
|
+
if (Array.isArray(delay)) return delay[index] ?? fallback;
|
|
44
|
+
if (typeof delay === "number") return delay;
|
|
45
|
+
return fallback;
|
|
46
|
+
}
|
|
52
47
|
|
|
53
|
-
|
|
54
|
-
this.
|
|
48
|
+
addEventListeners() {
|
|
49
|
+
this.triggerTarget.addEventListener("mouseenter", this.handleMouseEnter);
|
|
50
|
+
this.triggerTarget.addEventListener("mouseleave", this.handleMouseLeave);
|
|
51
|
+
this.triggerTarget.addEventListener("focusin", this.handleMouseEnter);
|
|
52
|
+
this.triggerTarget.addEventListener("focusout", this.handleMouseLeave);
|
|
53
|
+
this.contentTarget.addEventListener("mouseenter", this.handleMouseEnter);
|
|
54
|
+
this.contentTarget.addEventListener("mouseleave", this.handleMouseLeave);
|
|
55
|
+
this.contentTarget.addEventListener("focusin", this.handleMouseEnter);
|
|
56
|
+
this.contentTarget.addEventListener("focusout", this.handleMouseLeave);
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
removeEventListeners() {
|
|
60
|
+
this.triggerTarget.removeEventListener("mouseenter", this.handleMouseEnter);
|
|
61
|
+
this.triggerTarget.removeEventListener("mouseleave", this.handleMouseLeave);
|
|
62
|
+
this.triggerTarget.removeEventListener("focusin", this.handleMouseEnter);
|
|
63
|
+
this.triggerTarget.removeEventListener("focusout", this.handleMouseLeave);
|
|
64
|
+
this.contentTarget.removeEventListener("mouseenter", this.handleMouseEnter);
|
|
65
|
+
this.contentTarget.removeEventListener("mouseleave", this.handleMouseLeave);
|
|
66
|
+
this.contentTarget.removeEventListener("focusin", this.handleMouseEnter);
|
|
67
|
+
this.contentTarget.removeEventListener("focusout", this.handleMouseLeave);
|
|
61
68
|
}
|
|
62
69
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
handleMouseEnter = () => {
|
|
71
|
+
this.clearTimers();
|
|
72
|
+
this.openTimeout = setTimeout(() => this.show(), this.openDelay);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
handleMouseLeave = () => {
|
|
76
|
+
this.clearTimers();
|
|
77
|
+
this.closeTimeout = setTimeout(() => this.hide(), this.closeDelay);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
clearTimers() {
|
|
81
|
+
clearTimeout(this.openTimeout);
|
|
82
|
+
clearTimeout(this.closeTimeout);
|
|
69
83
|
}
|
|
70
84
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
this.
|
|
85
|
+
show() {
|
|
86
|
+
this.openValue = true;
|
|
87
|
+
this.contentTarget.classList.remove("hidden");
|
|
88
|
+
this.contentTarget.dataset.state = "open";
|
|
89
|
+
if (this.matchWidthValue) {
|
|
90
|
+
this.contentTarget.style.width = `${this.triggerTarget.offsetWidth}px`;
|
|
91
|
+
}
|
|
92
|
+
document.addEventListener("keydown", this.boundHandleKeydown);
|
|
93
|
+
this.updatePosition();
|
|
74
94
|
}
|
|
75
95
|
|
|
76
|
-
|
|
77
|
-
this.
|
|
96
|
+
hide() {
|
|
97
|
+
this.openValue = false;
|
|
98
|
+
this.contentTarget.classList.add("hidden");
|
|
99
|
+
this.contentTarget.dataset.state = "closed";
|
|
100
|
+
document.removeEventListener("keydown", this.boundHandleKeydown);
|
|
101
|
+
this.deselectAll();
|
|
102
|
+
if (this.cleanup) {
|
|
103
|
+
this.cleanup();
|
|
104
|
+
this.cleanup = null;
|
|
105
|
+
}
|
|
78
106
|
}
|
|
79
107
|
|
|
80
|
-
|
|
81
|
-
this.
|
|
108
|
+
updatePosition() {
|
|
109
|
+
if (this.cleanup) this.cleanup();
|
|
110
|
+
|
|
111
|
+
this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {
|
|
112
|
+
computePosition(this.triggerTarget, this.contentTarget, {
|
|
113
|
+
placement: this.optionsValue.placement || "bottom",
|
|
114
|
+
middleware: [offset(4), flip(), shift({ padding: 8 })],
|
|
115
|
+
}).then(({ x, y, placement }) => {
|
|
116
|
+
Object.assign(this.contentTarget.style, {
|
|
117
|
+
left: `${x}px`,
|
|
118
|
+
top: `${y}px`,
|
|
119
|
+
});
|
|
120
|
+
this.contentTarget.dataset.side = placement.split("-")[0];
|
|
121
|
+
});
|
|
122
|
+
});
|
|
82
123
|
}
|
|
83
124
|
|
|
84
125
|
handleKeydown(e) {
|
|
85
|
-
|
|
86
|
-
|
|
126
|
+
if (e.key === "Escape") {
|
|
127
|
+
this.hide();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (this.menuItemTargets.length === 0) return;
|
|
87
132
|
|
|
88
|
-
if (e.key ===
|
|
133
|
+
if (e.key === "ArrowDown") {
|
|
89
134
|
e.preventDefault();
|
|
90
135
|
this.updateSelectedItem(1);
|
|
91
|
-
} else if (e.key ===
|
|
136
|
+
} else if (e.key === "ArrowUp") {
|
|
92
137
|
e.preventDefault();
|
|
93
138
|
this.updateSelectedItem(-1);
|
|
94
|
-
} else if (e.key ===
|
|
139
|
+
} else if (e.key === "Enter" && this.selectedIndex !== -1) {
|
|
95
140
|
e.preventDefault();
|
|
96
141
|
this.menuItemTargets[this.selectedIndex].click();
|
|
97
142
|
}
|
|
98
143
|
}
|
|
99
144
|
|
|
100
145
|
updateSelectedItem(direction) {
|
|
101
|
-
// Check if any of the menuItemTargets have aria-selected="true" and set the selectedIndex to that index
|
|
102
146
|
this.menuItemTargets.forEach((item, index) => {
|
|
103
|
-
if (item.getAttribute(
|
|
147
|
+
if (item.getAttribute("aria-selected") === "true") {
|
|
104
148
|
this.selectedIndex = index;
|
|
105
149
|
}
|
|
106
150
|
});
|
|
@@ -121,24 +165,17 @@ export default class extends Controller {
|
|
|
121
165
|
}
|
|
122
166
|
|
|
123
167
|
toggleAriaSelected(element, isSelected) {
|
|
124
|
-
// Add or remove attribute
|
|
125
168
|
if (isSelected) {
|
|
126
|
-
element.setAttribute(
|
|
169
|
+
element.setAttribute("aria-selected", "true");
|
|
127
170
|
} else {
|
|
128
|
-
element.removeAttribute(
|
|
171
|
+
element.removeAttribute("aria-selected");
|
|
129
172
|
}
|
|
130
173
|
}
|
|
131
174
|
|
|
132
175
|
deselectAll() {
|
|
133
|
-
this.menuItemTargets.forEach(item =>
|
|
176
|
+
this.menuItemTargets.forEach((item) =>
|
|
177
|
+
this.toggleAriaSelected(item, false)
|
|
178
|
+
);
|
|
134
179
|
this.selectedIndex = -1;
|
|
135
180
|
}
|
|
136
|
-
|
|
137
|
-
addEventListeners() {
|
|
138
|
-
document.addEventListener('keydown', this.boundHandleKeydown);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
removeEventListeners() {
|
|
142
|
-
document.removeEventListener('keydown', this.boundHandleKeydown);
|
|
143
|
-
}
|
|
144
181
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUI
|
|
4
|
+
class InputOtp < Base
|
|
5
|
+
def initialize(length:, pattern: nil, **attrs)
|
|
6
|
+
@length = length
|
|
7
|
+
@char_class = pattern || "[0-9]"
|
|
8
|
+
super(**attrs)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def view_template(&block)
|
|
12
|
+
div(
|
|
13
|
+
data: {
|
|
14
|
+
controller: "ruby-ui--input-otp",
|
|
15
|
+
ruby_ui__input_otp_length_value: @length,
|
|
16
|
+
ruby_ui__input_otp_char_class_value: @char_class
|
|
17
|
+
},
|
|
18
|
+
class: "relative flex items-center has-disabled:opacity-50"
|
|
19
|
+
) do
|
|
20
|
+
div(class: "flex items-center gap-2", &block)
|
|
21
|
+
input(**attrs)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def default_attrs
|
|
28
|
+
{
|
|
29
|
+
type: "text",
|
|
30
|
+
inputmode: (@char_class == "[0-9]") ? "numeric" : "text",
|
|
31
|
+
pattern: "^(?:#{@char_class}){#{@length}}$",
|
|
32
|
+
maxlength: @length,
|
|
33
|
+
autocomplete: "one-time-code",
|
|
34
|
+
data: {
|
|
35
|
+
ruby_ui__input_otp_target: "input",
|
|
36
|
+
action: "input->ruby-ui--input-otp#onInput keydown->ruby-ui--input-otp#onKeydown paste->ruby-ui--input-otp#onPaste focus->ruby-ui--input-otp#onFocus blur->ruby-ui--input-otp#onBlur"
|
|
37
|
+
},
|
|
38
|
+
class: "absolute inset-0 h-full w-full cursor-text appearance-none border-0 bg-transparent p-0 text-transparent caret-transparent shadow-none outline-none selection:bg-transparent focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed"
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Connects to data-controller="ruby-ui--input-otp"
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static targets = ["input", "slot"]
|
|
6
|
+
static values = { length: Number, charClass: String }
|
|
7
|
+
|
|
8
|
+
connect() {
|
|
9
|
+
// A server-rendered value (prefilled from a previous submission, a
|
|
10
|
+
// validation error, etc.) may exceed length or contain characters that
|
|
11
|
+
// fail pattern. Sanitize it up front so the hidden slots never mask
|
|
12
|
+
// an invalid/oversized value that would otherwise still get submitted.
|
|
13
|
+
this.sanitizeValue()
|
|
14
|
+
this.paint()
|
|
15
|
+
this.boundOnSelectionChange = this.onSelectionChange.bind(this)
|
|
16
|
+
document.addEventListener("selectionchange", this.boundOnSelectionChange)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
disconnect() {
|
|
20
|
+
document.removeEventListener("selectionchange", this.boundOnSelectionChange)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
onInput() {
|
|
24
|
+
this.sanitizeValue()
|
|
25
|
+
const filtered = this.inputTarget.value
|
|
26
|
+
|
|
27
|
+
this.normalizeSelection()
|
|
28
|
+
this.paint()
|
|
29
|
+
this.dispatch("input", { detail: { value: filtered } })
|
|
30
|
+
if (filtered.length === this.lengthValue) {
|
|
31
|
+
this.dispatch("complete", { detail: { value: filtered } })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
onFocus() {
|
|
36
|
+
const end = this.inputTarget.value.length
|
|
37
|
+
const start = Math.min(end, this.lengthValue - 1)
|
|
38
|
+
this.inputTarget.setSelectionRange(start, end)
|
|
39
|
+
this.paint()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
onBlur() {
|
|
43
|
+
this.paint()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
onKeydown(event) {
|
|
47
|
+
const moves = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }
|
|
48
|
+
if (!(event.key in moves)) return
|
|
49
|
+
|
|
50
|
+
event.preventDefault()
|
|
51
|
+
const current = this.inputTarget.selectionStart ?? 0
|
|
52
|
+
const next = Math.min(Math.max(current + moves[event.key], 0), this.lengthValue - 1)
|
|
53
|
+
const hasChar = next < this.inputTarget.value.length
|
|
54
|
+
this.inputTarget.setSelectionRange(next, hasChar ? next + 1 : next)
|
|
55
|
+
this.paint()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
onPaste(event) {
|
|
59
|
+
event.preventDefault()
|
|
60
|
+
const pasted = this.filter(event.clipboardData.getData("text/plain"))
|
|
61
|
+
if (!pasted) return
|
|
62
|
+
|
|
63
|
+
const start = this.inputTarget.selectionStart ?? 0
|
|
64
|
+
const end = this.inputTarget.selectionEnd ?? start
|
|
65
|
+
const current = this.inputTarget.value
|
|
66
|
+
const merged = (current.slice(0, start) + pasted + current.slice(end)).slice(0, this.lengthValue)
|
|
67
|
+
|
|
68
|
+
this.inputTarget.value = merged
|
|
69
|
+
const caret = Math.min(merged.length, this.lengthValue - 1)
|
|
70
|
+
this.inputTarget.setSelectionRange(caret, merged.length)
|
|
71
|
+
|
|
72
|
+
this.paint()
|
|
73
|
+
this.dispatch("input", { detail: { value: merged } })
|
|
74
|
+
if (merged.length === this.lengthValue) this.dispatch("complete", { detail: { value: merged } })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
onSelectionChange() {
|
|
78
|
+
if (document.activeElement !== this.inputTarget) return
|
|
79
|
+
this.paint()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// After typing, replacing, or deleting, the browser leaves a collapsed
|
|
83
|
+
// caret. If it landed on a slot that already has a character (not the
|
|
84
|
+
// true insert-mode end of the value), re-select that character as a
|
|
85
|
+
// 1-char range so the next keystroke replaces it instead of being
|
|
86
|
+
// silently dropped by the native maxlength/no-selection behavior.
|
|
87
|
+
normalizeSelection() {
|
|
88
|
+
const input = this.inputTarget
|
|
89
|
+
const value = input.value
|
|
90
|
+
const s = input.selectionStart
|
|
91
|
+
const e = input.selectionEnd
|
|
92
|
+
if (s === null || e === null || s !== e) return
|
|
93
|
+
|
|
94
|
+
const isInsertMode = s === value.length && value.length < this.lengthValue
|
|
95
|
+
if (isInsertMode) return
|
|
96
|
+
|
|
97
|
+
const index = Math.min(s, this.lengthValue - 1)
|
|
98
|
+
input.setSelectionRange(index, index < value.length ? index + 1 : index)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
filter(raw) {
|
|
102
|
+
const re = new RegExp(this.charClassValue)
|
|
103
|
+
return raw.split("").filter((char) => re.test(char)).join("")
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
sanitizeValue() {
|
|
107
|
+
const filtered = this.filter(this.inputTarget.value).slice(0, this.lengthValue)
|
|
108
|
+
if (filtered !== this.inputTarget.value) this.inputTarget.value = filtered
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
paint() {
|
|
112
|
+
const value = this.inputTarget.value
|
|
113
|
+
const isFocused = document.activeElement === this.inputTarget
|
|
114
|
+
const start = this.inputTarget.selectionStart ?? value.length
|
|
115
|
+
const end = this.inputTarget.selectionEnd ?? value.length
|
|
116
|
+
const activeIndex = Math.min(start, this.lengthValue - 1)
|
|
117
|
+
|
|
118
|
+
this.slotTargets.forEach((slot) => {
|
|
119
|
+
const index = Number(slot.dataset.index)
|
|
120
|
+
const char = value[index] ?? ""
|
|
121
|
+
const isActive = isFocused && ((start === end && index === activeIndex) || (index >= start && index < end))
|
|
122
|
+
|
|
123
|
+
slot.textContent = char
|
|
124
|
+
slot.dataset.active = isActive ? "true" : "false"
|
|
125
|
+
slot.dataset.caret = isActive && char === "" ? "true" : "false"
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
}
|