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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/ruby_ui/dependencies.yml +12 -2
  3. data/lib/generators/ruby_ui/javascript_utils.rb +0 -13
  4. data/lib/ruby_ui/accordion/accordion_controller.js +16 -9
  5. data/lib/ruby_ui/bubble/bubble.rb +37 -0
  6. data/lib/ruby_ui/bubble/bubble_content.rb +23 -0
  7. data/lib/ruby_ui/bubble/bubble_docs.rb +167 -0
  8. data/lib/ruby_ui/bubble/bubble_group.rb +18 -0
  9. data/lib/ruby_ui/bubble/bubble_reactions.rb +38 -0
  10. data/lib/ruby_ui/combobox/combobox.rb +3 -1
  11. data/lib/ruby_ui/combobox/combobox_checkbox.rb +8 -1
  12. data/lib/ruby_ui/combobox/combobox_controller.js +3 -2
  13. data/lib/ruby_ui/combobox/combobox_trigger.rb +1 -4
  14. data/lib/ruby_ui/context_menu/context_menu_content.rb +5 -6
  15. data/lib/ruby_ui/context_menu/context_menu_controller.js +87 -75
  16. data/lib/ruby_ui/data_table/data_table_column_toggle.rb +8 -4
  17. data/lib/ruby_ui/data_table/data_table_column_visibility_controller.js +12 -2
  18. data/lib/ruby_ui/dialog/dialog_content.rb +1 -1
  19. data/lib/ruby_ui/dropdown_menu/dropdown_menu.rb +0 -1
  20. data/lib/ruby_ui/dropdown_menu/dropdown_menu_controller.js +5 -1
  21. data/lib/ruby_ui/dropdown_menu/dropdown_menu_docs.rb +1 -1
  22. data/lib/ruby_ui/empty/empty.rb +18 -0
  23. data/lib/ruby_ui/empty/empty_content.rb +18 -0
  24. data/lib/ruby_ui/empty/empty_description.rb +18 -0
  25. data/lib/ruby_ui/empty/empty_docs.rb +69 -0
  26. data/lib/ruby_ui/empty/empty_header.rb +18 -0
  27. data/lib/ruby_ui/empty/empty_media.rb +31 -0
  28. data/lib/ruby_ui/empty/empty_title.rb +18 -0
  29. data/lib/ruby_ui/hover_card/hover_card_content.rb +5 -6
  30. data/lib/ruby_ui/hover_card/hover_card_controller.js +113 -76
  31. data/lib/ruby_ui/input_otp/input_otp.rb +42 -0
  32. data/lib/ruby_ui/input_otp/input_otp_controller.js +128 -0
  33. data/lib/ruby_ui/input_otp/input_otp_docs.rb +213 -0
  34. data/lib/ruby_ui/input_otp/input_otp_group.rb +15 -0
  35. data/lib/ruby_ui/input_otp/input_otp_separator.rb +39 -0
  36. data/lib/ruby_ui/input_otp/input_otp_slot.rb +33 -0
  37. data/lib/ruby_ui/message/message.rb +23 -0
  38. data/lib/ruby_ui/message/message_avatar.rb +18 -0
  39. data/lib/ruby_ui/message/message_content.rb +18 -0
  40. data/lib/ruby_ui/message/message_docs.rb +173 -0
  41. data/lib/ruby_ui/message/message_footer.rb +18 -0
  42. data/lib/ruby_ui/message/message_group.rb +18 -0
  43. data/lib/ruby_ui/message/message_header.rb +18 -0
  44. data/lib/ruby_ui/message_scroller/message_scroller.rb +18 -0
  45. data/lib/ruby_ui/message_scroller/message_scroller_button.rb +56 -0
  46. data/lib/ruby_ui/message_scroller/message_scroller_content.rb +23 -0
  47. data/lib/ruby_ui/message_scroller/message_scroller_controller.js +335 -0
  48. data/lib/ruby_ui/message_scroller/message_scroller_docs.rb +208 -0
  49. data/lib/ruby_ui/message_scroller/message_scroller_item.rb +28 -0
  50. data/lib/ruby_ui/message_scroller/message_scroller_provider.rb +34 -0
  51. data/lib/ruby_ui/message_scroller/message_scroller_viewport.rb +22 -0
  52. data/lib/ruby_ui/native_select/native_select_icon.rb +1 -1
  53. data/lib/ruby_ui/popover/popover_content.rb +2 -1
  54. data/lib/ruby_ui/popover/popover_controller.js +95 -32
  55. data/lib/ruby_ui/toast/toaster_controller.js +24 -8
  56. data/lib/ruby_ui.rb +1 -1
  57. metadata +34 -1
@@ -1,106 +1,150 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
- import tippy from "tippy.js";
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
- options: {
8
- type: Object,
9
- default: {},
10
- },
11
- // make content width of the trigger element (true/false)
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.boundHandleKeydown = this.handleKeydown.bind(this); // Bind the function so we can remove it later
20
- this.initializeTippy();
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.destroyTippy();
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
- initializeTippy() {
29
- const defaultOptions = {
30
- content: this.contentTarget.innerHTML,
31
- allowHTML: true,
32
- interactive: true,
33
- onShow: (instance) => {
34
- this.matchWidthValue && this.setContentWidth(instance); // ensure content width matches trigger width
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
- const mergedOptions = { ...this.optionsValue, ...defaultOptions };
54
- this.tippy = tippy(this.triggerTarget, mergedOptions);
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
- destroyTippy() {
58
- if (this.tippy) {
59
- this.tippy.destroy();
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
- setContentWidth(instance) {
64
- // box-sizing: border-box
65
- const content = instance.popper.querySelector('.tippy-content');
66
- if (content) {
67
- content.style.width = `${instance.reference.offsetWidth}px`;
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
- handleContextMenu(event) {
72
- event.preventDefault();
73
- this.open();
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
- open() {
77
- this.tippy.show();
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
- close() {
81
- this.tippy.hide();
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
- // return if no menu items (one line fix for)
86
- if (this.menuItemTargets.length === 0) { return; }
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 === 'ArrowDown') {
133
+ if (e.key === "ArrowDown") {
89
134
  e.preventDefault();
90
135
  this.updateSelectedItem(1);
91
- } else if (e.key === 'ArrowUp') {
136
+ } else if (e.key === "ArrowUp") {
92
137
  e.preventDefault();
93
138
  this.updateSelectedItem(-1);
94
- } else if (e.key === 'Enter' && this.selectedIndex !== -1) {
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('aria-selected') === 'true') {
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('aria-selected', 'true');
169
+ element.setAttribute("aria-selected", "true");
127
170
  } else {
128
- element.removeAttribute('aria-selected');
171
+ element.removeAttribute("aria-selected");
129
172
  }
130
173
  }
131
174
 
132
175
  deselectAll() {
133
- this.menuItemTargets.forEach(item => this.toggleAriaSelected(item, false));
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,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class InputOtpGroup < Base
5
+ def view_template(&block)
6
+ div(**attrs, &block)
7
+ end
8
+
9
+ private
10
+
11
+ def default_attrs
12
+ {class: "flex items-center"}
13
+ end
14
+ end
15
+ 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