ruby_ui 1.4.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/lib/generators/ruby_ui/dependencies.yml +12 -2
- data/lib/generators/ruby_ui/javascript_utils.rb +0 -13
- data/lib/ruby_ui/accordion/accordion_controller.js +16 -9
- 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/dialog/dialog_content.rb +1 -1
- 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/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/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/toast/toaster_controller.js +24 -8
- data/lib/ruby_ui.rb +1 -1
- metadata +34 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Views::Docs::InputOtp < Views::Base
|
|
4
|
+
def view_template
|
|
5
|
+
component = "InputOtp"
|
|
6
|
+
|
|
7
|
+
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
|
|
8
|
+
render Docs::Header.new(title: "Input OTP", description: "Accessible one-time-password input with keyboard navigation and paste support.")
|
|
9
|
+
|
|
10
|
+
Heading(level: 2) { "Usage" }
|
|
11
|
+
|
|
12
|
+
render Docs::VisualCodeExample.new(title: "Example", context: self) do
|
|
13
|
+
<<~RUBY
|
|
14
|
+
InputOtp(length: 6, name: "otp") do
|
|
15
|
+
InputOtpGroup do
|
|
16
|
+
InputOtpSlot(index: 0)
|
|
17
|
+
InputOtpSlot(index: 1)
|
|
18
|
+
InputOtpSlot(index: 2)
|
|
19
|
+
InputOtpSlot(index: 3)
|
|
20
|
+
InputOtpSlot(index: 4)
|
|
21
|
+
InputOtpSlot(index: 5)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
RUBY
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
Heading(level: 2) { "Composition" }
|
|
28
|
+
|
|
29
|
+
Text { "InputOtpGroup and InputOtpSeparator compose freely — split slots into however many groups make sense, with a separator between each." }
|
|
30
|
+
|
|
31
|
+
render Docs::VisualCodeExample.new(title: "Example", context: self) do
|
|
32
|
+
<<~RUBY
|
|
33
|
+
InputOtp(length: 6, name: "otp") do
|
|
34
|
+
InputOtpGroup do
|
|
35
|
+
InputOtpSlot(index: 0)
|
|
36
|
+
InputOtpSlot(index: 1)
|
|
37
|
+
end
|
|
38
|
+
InputOtpSeparator()
|
|
39
|
+
InputOtpGroup do
|
|
40
|
+
InputOtpSlot(index: 2)
|
|
41
|
+
InputOtpSlot(index: 3)
|
|
42
|
+
end
|
|
43
|
+
InputOtpSeparator()
|
|
44
|
+
InputOtpGroup do
|
|
45
|
+
InputOtpSlot(index: 4)
|
|
46
|
+
InputOtpSlot(index: 5)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
RUBY
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Heading(level: 2) { "Pattern" }
|
|
53
|
+
|
|
54
|
+
Text { "Pass pattern: with a single-character regex class to define what InputOtp accepts. The default is \"[0-9]\" (digits only)." }
|
|
55
|
+
|
|
56
|
+
render Docs::VisualCodeExample.new(title: "Alphanumeric", context: self) do
|
|
57
|
+
<<~RUBY
|
|
58
|
+
InputOtp(length: 6, name: "otp", pattern: "[0-9A-Za-z]") do
|
|
59
|
+
InputOtpGroup do
|
|
60
|
+
InputOtpSlot(index: 0)
|
|
61
|
+
InputOtpSlot(index: 1)
|
|
62
|
+
InputOtpSlot(index: 2)
|
|
63
|
+
InputOtpSlot(index: 3)
|
|
64
|
+
InputOtpSlot(index: 4)
|
|
65
|
+
InputOtpSlot(index: 5)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
RUBY
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
Heading(level: 2) { "Four digits" }
|
|
72
|
+
|
|
73
|
+
Text { "A common pattern for PIN codes — just pass a shorter length." }
|
|
74
|
+
|
|
75
|
+
render Docs::VisualCodeExample.new(title: "Example", context: self) do
|
|
76
|
+
<<~RUBY
|
|
77
|
+
InputOtp(length: 4, name: "pin") do
|
|
78
|
+
InputOtpGroup do
|
|
79
|
+
InputOtpSlot(index: 0)
|
|
80
|
+
InputOtpSlot(index: 1)
|
|
81
|
+
InputOtpSlot(index: 2)
|
|
82
|
+
InputOtpSlot(index: 3)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
RUBY
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
Heading(level: 2) { "Disabled" }
|
|
89
|
+
|
|
90
|
+
render Docs::VisualCodeExample.new(title: "Example", context: self) do
|
|
91
|
+
<<~RUBY
|
|
92
|
+
InputOtp(length: 6, name: "otp", disabled: true) do
|
|
93
|
+
InputOtpGroup do
|
|
94
|
+
InputOtpSlot(index: 0)
|
|
95
|
+
InputOtpSlot(index: 1)
|
|
96
|
+
InputOtpSlot(index: 2)
|
|
97
|
+
InputOtpSlot(index: 3)
|
|
98
|
+
InputOtpSlot(index: 4)
|
|
99
|
+
InputOtpSlot(index: 5)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
RUBY
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
Heading(level: 2) { "Invalid" }
|
|
106
|
+
|
|
107
|
+
Text { "Pass aria_invalid: \"true\" to each InputOtpSlot to show an error state." }
|
|
108
|
+
|
|
109
|
+
render Docs::VisualCodeExample.new(title: "Example", context: self) do
|
|
110
|
+
<<~RUBY
|
|
111
|
+
InputOtp(length: 6, name: "otp") do
|
|
112
|
+
InputOtpGroup do
|
|
113
|
+
InputOtpSlot(index: 0, aria_invalid: "true")
|
|
114
|
+
InputOtpSlot(index: 1, aria_invalid: "true")
|
|
115
|
+
InputOtpSlot(index: 2, aria_invalid: "true")
|
|
116
|
+
InputOtpSlot(index: 3, aria_invalid: "true")
|
|
117
|
+
InputOtpSlot(index: 4, aria_invalid: "true")
|
|
118
|
+
InputOtpSlot(index: 5, aria_invalid: "true")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
RUBY
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
Heading(level: 2) { "Form" }
|
|
125
|
+
|
|
126
|
+
Text { "A full example combining InputOtp with Card, Button, and InlineLink — bigger slots via class:, a resend action, and fallback links." }
|
|
127
|
+
|
|
128
|
+
render Docs::VisualCodeExample.new(title: "Verify your login", context: self) do
|
|
129
|
+
<<~RUBY
|
|
130
|
+
Card(class: "mx-auto max-w-md") do
|
|
131
|
+
CardHeader do
|
|
132
|
+
CardTitle { "Verify your login" }
|
|
133
|
+
CardDescription do
|
|
134
|
+
plain "Enter the verification code we sent to your email address: "
|
|
135
|
+
span(class: "font-medium") { "m@example.com" }
|
|
136
|
+
plain "."
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
CardContent(class: "space-y-4") do
|
|
140
|
+
div(class: "flex items-center justify-between") do
|
|
141
|
+
label(class: "text-sm font-medium") { "Verification code" }
|
|
142
|
+
Button(variant: :outline, size: :sm) do
|
|
143
|
+
svg(
|
|
144
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
145
|
+
viewbox: "0 0 24 24",
|
|
146
|
+
fill: "none",
|
|
147
|
+
stroke: "currentColor",
|
|
148
|
+
stroke_width: "2",
|
|
149
|
+
stroke_linecap: "round",
|
|
150
|
+
stroke_linejoin: "round",
|
|
151
|
+
class: "w-4 h-4 mr-2"
|
|
152
|
+
) do |s|
|
|
153
|
+
s.path(d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8")
|
|
154
|
+
s.path(d: "M21 3v5h-5")
|
|
155
|
+
s.path(d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16")
|
|
156
|
+
s.path(d: "M8 16H3v5")
|
|
157
|
+
end
|
|
158
|
+
plain "Resend Code"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
InputOtp(length: 6, name: "otp", required: true) do
|
|
162
|
+
InputOtpGroup do
|
|
163
|
+
InputOtpSlot(index: 0, class: "h-12 w-11 text-xl")
|
|
164
|
+
InputOtpSlot(index: 1, class: "h-12 w-11 text-xl")
|
|
165
|
+
InputOtpSlot(index: 2, class: "h-12 w-11 text-xl")
|
|
166
|
+
end
|
|
167
|
+
InputOtpSeparator(class: "mx-2")
|
|
168
|
+
InputOtpGroup do
|
|
169
|
+
InputOtpSlot(index: 3, class: "h-12 w-11 text-xl")
|
|
170
|
+
InputOtpSlot(index: 4, class: "h-12 w-11 text-xl")
|
|
171
|
+
InputOtpSlot(index: 5, class: "h-12 w-11 text-xl")
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
InlineLink(href: "#") { "I no longer have access to this email address." }
|
|
175
|
+
end
|
|
176
|
+
CardFooter(class: "flex flex-col items-stretch gap-2") do
|
|
177
|
+
Button(class: "w-full") { "Verify" }
|
|
178
|
+
Text(size: "sm", weight: "muted") do
|
|
179
|
+
plain "Having trouble signing in? "
|
|
180
|
+
InlineLink(href: "#") { "Contact support" }
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
RUBY
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
Heading(level: 2) { "Reacting to completion" }
|
|
188
|
+
|
|
189
|
+
Text { "The controller dispatches a ruby-ui--input-otp:complete custom event (detail: { value }) once the value reaches length characters, and a ruby-ui--input-otp:input event on every change. Wire a Stimulus action on a parent element to react — for example, to auto-submit a form:" }
|
|
190
|
+
|
|
191
|
+
Codeblock(<<~JS, syntax: :javascript)
|
|
192
|
+
// app/javascript/controllers/otp_form_controller.js
|
|
193
|
+
import { Controller } from "@hotwired/stimulus"
|
|
194
|
+
|
|
195
|
+
export default class extends Controller {
|
|
196
|
+
submit() {
|
|
197
|
+
this.element.requestSubmit()
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
JS
|
|
201
|
+
|
|
202
|
+
Codeblock(<<~HTML, syntax: :html)
|
|
203
|
+
<form data-controller="otp-form" data-action="ruby-ui--input-otp:complete->otp-form#submit">
|
|
204
|
+
<!-- InputOtp(...) here -->
|
|
205
|
+
</form>
|
|
206
|
+
HTML
|
|
207
|
+
|
|
208
|
+
render Components::ComponentSetup::Tabs.new(component_name: component)
|
|
209
|
+
|
|
210
|
+
render Docs::ComponentsTable.new(component_files(component))
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUI
|
|
4
|
+
class InputOtpSeparator < Base
|
|
5
|
+
def view_template(&block)
|
|
6
|
+
div(**attrs) do
|
|
7
|
+
if block
|
|
8
|
+
block.call
|
|
9
|
+
else
|
|
10
|
+
icon
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def icon
|
|
16
|
+
svg(
|
|
17
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
18
|
+
viewbox: "0 0 24 24",
|
|
19
|
+
fill: "none",
|
|
20
|
+
stroke: "currentColor",
|
|
21
|
+
stroke_width: "2",
|
|
22
|
+
stroke_linecap: "round",
|
|
23
|
+
stroke_linejoin: "round",
|
|
24
|
+
class: "h-4 w-4"
|
|
25
|
+
) do |s|
|
|
26
|
+
s.path(d: "M5 12h14")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def default_attrs
|
|
33
|
+
{
|
|
34
|
+
role: "separator",
|
|
35
|
+
class: "text-muted-foreground"
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|