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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -0
  3. data/lib/generators/ruby_ui/component/all_generator.rb +6 -4
  4. data/lib/generators/ruby_ui/component_generator.rb +51 -31
  5. data/lib/generators/ruby_ui/dependencies.yml +12 -2
  6. data/lib/generators/ruby_ui/javascript_utils.rb +0 -13
  7. data/lib/ruby_ui/accordion/accordion_content.rb +4 -2
  8. data/lib/ruby_ui/accordion/accordion_controller.js +28 -6
  9. data/lib/ruby_ui/avatar/avatar_image.rb +5 -1
  10. data/lib/ruby_ui/bubble/bubble.rb +37 -0
  11. data/lib/ruby_ui/bubble/bubble_content.rb +23 -0
  12. data/lib/ruby_ui/bubble/bubble_docs.rb +167 -0
  13. data/lib/ruby_ui/bubble/bubble_group.rb +18 -0
  14. data/lib/ruby_ui/bubble/bubble_reactions.rb +38 -0
  15. data/lib/ruby_ui/combobox/combobox.rb +3 -1
  16. data/lib/ruby_ui/combobox/combobox_checkbox.rb +8 -1
  17. data/lib/ruby_ui/combobox/combobox_controller.js +3 -2
  18. data/lib/ruby_ui/combobox/combobox_trigger.rb +1 -4
  19. data/lib/ruby_ui/context_menu/context_menu_content.rb +5 -6
  20. data/lib/ruby_ui/context_menu/context_menu_controller.js +87 -75
  21. data/lib/ruby_ui/data_table/data_table_column_toggle.rb +8 -4
  22. data/lib/ruby_ui/data_table/data_table_column_visibility_controller.js +12 -2
  23. data/lib/ruby_ui/data_table/data_table_docs.rb +1 -1
  24. data/lib/ruby_ui/dialog/dialog_content.rb +7 -19
  25. data/lib/ruby_ui/dialog/dialog_controller.js +22 -10
  26. data/lib/ruby_ui/dropdown_menu/dropdown_menu.rb +0 -1
  27. data/lib/ruby_ui/dropdown_menu/dropdown_menu_controller.js +5 -1
  28. data/lib/ruby_ui/dropdown_menu/dropdown_menu_docs.rb +1 -1
  29. data/lib/ruby_ui/dropdown_menu/dropdown_menu_item.rb +10 -4
  30. data/lib/ruby_ui/empty/empty.rb +18 -0
  31. data/lib/ruby_ui/empty/empty_content.rb +18 -0
  32. data/lib/ruby_ui/empty/empty_description.rb +18 -0
  33. data/lib/ruby_ui/empty/empty_docs.rb +69 -0
  34. data/lib/ruby_ui/empty/empty_header.rb +18 -0
  35. data/lib/ruby_ui/empty/empty_media.rb +31 -0
  36. data/lib/ruby_ui/empty/empty_title.rb +18 -0
  37. data/lib/ruby_ui/form/form_docs.rb +89 -0
  38. data/lib/ruby_ui/hover_card/hover_card_content.rb +5 -6
  39. data/lib/ruby_ui/hover_card/hover_card_controller.js +113 -76
  40. data/lib/ruby_ui/input_otp/input_otp.rb +42 -0
  41. data/lib/ruby_ui/input_otp/input_otp_controller.js +128 -0
  42. data/lib/ruby_ui/input_otp/input_otp_docs.rb +213 -0
  43. data/lib/ruby_ui/input_otp/input_otp_group.rb +15 -0
  44. data/lib/ruby_ui/input_otp/input_otp_separator.rb +39 -0
  45. data/lib/ruby_ui/input_otp/input_otp_slot.rb +33 -0
  46. data/lib/ruby_ui/message/message.rb +23 -0
  47. data/lib/ruby_ui/message/message_avatar.rb +18 -0
  48. data/lib/ruby_ui/message/message_content.rb +18 -0
  49. data/lib/ruby_ui/message/message_docs.rb +173 -0
  50. data/lib/ruby_ui/message/message_footer.rb +18 -0
  51. data/lib/ruby_ui/message/message_group.rb +18 -0
  52. data/lib/ruby_ui/message/message_header.rb +18 -0
  53. data/lib/ruby_ui/message_scroller/message_scroller.rb +18 -0
  54. data/lib/ruby_ui/message_scroller/message_scroller_button.rb +56 -0
  55. data/lib/ruby_ui/message_scroller/message_scroller_content.rb +23 -0
  56. data/lib/ruby_ui/message_scroller/message_scroller_controller.js +335 -0
  57. data/lib/ruby_ui/message_scroller/message_scroller_docs.rb +208 -0
  58. data/lib/ruby_ui/message_scroller/message_scroller_item.rb +28 -0
  59. data/lib/ruby_ui/message_scroller/message_scroller_provider.rb +34 -0
  60. data/lib/ruby_ui/message_scroller/message_scroller_viewport.rb +22 -0
  61. data/lib/ruby_ui/native_select/native_select_icon.rb +1 -1
  62. data/lib/ruby_ui/popover/popover_content.rb +2 -1
  63. data/lib/ruby_ui/popover/popover_controller.js +95 -32
  64. data/lib/ruby_ui/table/table_docs.rb +2 -2
  65. data/lib/ruby_ui/tabs/tabs_docs.rb +1 -1
  66. data/lib/ruby_ui/tabs/tabs_trigger.rb +10 -4
  67. data/lib/ruby_ui/toast/toaster_controller.js +24 -8
  68. data/lib/ruby_ui.rb +1 -1
  69. 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(&block)
6
- template(data: {ruby_ui__hover_card_target: "content"}) do
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
- state: :open
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 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
+ }