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
@@ -19,13 +19,20 @@ export default class extends Controller {
19
19
  this.closeTimeout = null;
20
20
  this.cleanup = null;
21
21
  this.addEventListeners();
22
+ // openValue lives in the DOM, so a reconnect (frame swap, morph, moved element)
23
+ // arrives already open. Re-arm the parts that live on the controller instead of
24
+ // the markup — the keydown listener and the autoUpdate positioning.
25
+ if (this.openValue) this.showPopover();
22
26
  }
23
27
 
28
+ // Teardown that cannot fail comes first: resolving a target throws once the
29
+ // element is gone, and Stimulus swallows that, skipping the rest of disconnect.
24
30
  disconnect() {
25
- this.removeEventListeners();
26
- if (this.cleanup) {
27
- this.cleanup();
28
- }
31
+ clearTimeout(this.closeTimeout);
32
+ document.removeEventListener("keydown", this.handleKeydown);
33
+ document.removeEventListener("click", this.handleOutsideClick);
34
+ this.stopAutoUpdate();
35
+ this.removeElementEventListeners();
29
36
  }
30
37
 
31
38
  addEventListeners() {
@@ -40,68 +47,124 @@ export default class extends Controller {
40
47
  }
41
48
  }
42
49
 
43
- removeEventListeners() {
44
- this.triggerTarget.removeEventListener("mouseenter", this.handleMouseEnter);
45
- this.triggerTarget.removeEventListener("mouseleave", this.handleMouseLeave);
46
- this.contentTarget.removeEventListener("mouseenter", this.handleMouseEnter);
47
- this.contentTarget.removeEventListener("mouseleave", this.handleMouseLeave);
48
- this.triggerTarget.removeEventListener("click", this.handleClick);
49
- document.removeEventListener("click", this.handleOutsideClick);
50
+ // Each target is guarded on its own: losing one of them must not strand the
51
+ // listeners attached to the other.
52
+ removeElementEventListeners() {
53
+ if (this.hasTriggerTarget) {
54
+ this.triggerTarget.removeEventListener("mouseenter", this.handleMouseEnter);
55
+ this.triggerTarget.removeEventListener("mouseleave", this.handleMouseLeave);
56
+ this.triggerTarget.removeEventListener("click", this.handleClick);
57
+ }
58
+
59
+ if (this.hasContentTarget) {
60
+ this.contentTarget.removeEventListener("mouseenter", this.handleMouseEnter);
61
+ this.contentTarget.removeEventListener("mouseleave", this.handleMouseLeave);
62
+ }
50
63
  }
51
64
 
52
65
  handleMouseEnter = () => {
53
66
  clearTimeout(this.closeTimeout);
54
- this.openValue = true;
55
67
  this.showPopover();
56
68
  };
57
69
 
58
70
  handleMouseLeave = () => {
59
- this.closeTimeout = setTimeout(() => {
60
- this.openValue = false;
61
- this.hidePopover();
62
- }, 100);
71
+ this.closeTimeout = setTimeout(() => this.hidePopover(), 100);
63
72
  };
64
73
 
65
74
  handleClick = (event) => {
66
75
  event.stopPropagation();
67
- this.openValue = !this.openValue;
68
- this.openValue ? this.showPopover() : this.hidePopover();
76
+ this.openValue ? this.hidePopover() : this.showPopover();
69
77
  };
70
78
 
71
79
  handleOutsideClick = (event) => {
72
- if (!this.element.contains(event.target) && this.openValue) {
73
- this.openValue = false;
74
- this.hidePopover();
75
- }
80
+ if (this.element.contains(event.target)) return;
81
+ if (!this.openValue) return;
82
+
83
+ this.hidePopover();
76
84
  };
77
85
 
86
+ handleKeydown = (event) => {
87
+ if (event.key !== "Escape") return;
88
+ if (!this.openValue) return;
89
+
90
+ clearTimeout(this.closeTimeout);
91
+ this.hidePopover();
92
+ };
93
+
94
+ // openValue is set here rather than by the callers, so a guarded early return can
95
+ // never leave the DOM claiming the popover is open while nothing is wired up.
78
96
  showPopover() {
97
+ if (!this.hasTriggerTarget || !this.hasContentTarget) return;
98
+
99
+ this.openValue = true;
79
100
  this.contentTarget.classList.remove("hidden");
101
+ this.contentTarget.dataset.state = "open";
102
+ document.addEventListener("keydown", this.handleKeydown);
80
103
  this.updatePosition();
81
104
  }
82
105
 
106
+ // Same rule as disconnect(): release what is held outside the element first, so a
107
+ // missing content target cannot leave the keydown listener or autoUpdate running.
83
108
  hidePopover() {
109
+ this.openValue = false;
110
+ document.removeEventListener("keydown", this.handleKeydown);
111
+ this.stopAutoUpdate();
112
+
113
+ if (!this.hasContentTarget) return;
114
+
84
115
  this.contentTarget.classList.add("hidden");
85
- if (this.cleanup) {
86
- this.cleanup();
87
- }
116
+ this.contentTarget.dataset.state = "closed";
88
117
  }
89
118
 
90
119
  updatePosition() {
91
- if (this.cleanup) {
92
- this.cleanup();
93
- }
120
+ this.stopAutoUpdate();
121
+
122
+ // Hold the exact pair this run positions. A target can be detached or swapped
123
+ // while the controller stays connected, and the stale element must not be
124
+ // written to by an observer callback or an in-flight computePosition.
125
+ const trigger = this.triggerTarget;
126
+ const content = this.contentTarget;
127
+
128
+ // Deferred teardown is bound to this run's own handle, so a newer positioning
129
+ // run installed before the microtask drains is never torn down by an older one.
130
+ let stop = null;
131
+ const releaseThisRun = () => {
132
+ stop?.();
133
+ if (this.cleanup === stop) this.cleanup = null;
134
+ };
94
135
 
95
- this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {
96
- computePosition(this.triggerTarget, this.contentTarget, {
136
+ stop = autoUpdate(trigger, content, () => {
137
+ if (!trigger.isConnected || !content.isConnected) {
138
+ // Release the observers instead of throwing on every scroll and resize.
139
+ // Deferred because autoUpdate runs this once synchronously, before the
140
+ // handle below has been assigned.
141
+ queueMicrotask(releaseThisRun);
142
+ return;
143
+ }
144
+
145
+ computePosition(trigger, content, {
97
146
  placement: this.optionsValue.placement || "bottom",
98
147
  middleware: [flip(), shift(), offset(8)],
99
- }).then(({ x, y }) => {
100
- Object.assign(this.contentTarget.style, {
148
+ }).then(({ x, y, placement }) => {
149
+ if (!content.isConnected) return;
150
+
151
+ Object.assign(content.style, {
101
152
  left: `${x}px`,
102
153
  top: `${y}px`,
103
154
  });
155
+ // flip() can resolve to the opposite side of the requested placement,
156
+ // so the directional slide-in classes must follow the resolved value.
157
+ content.dataset.side = placement.split("-")[0];
104
158
  });
105
159
  });
160
+
161
+ this.cleanup = stop;
162
+ }
163
+
164
+ stopAutoUpdate() {
165
+ if (!this.cleanup) return;
166
+
167
+ this.cleanup();
168
+ this.cleanup = null;
106
169
  }
107
170
  }
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Views::Docs::Table < Views::Base
4
- Invoice = Struct.new(:identifier, :status, :method, :amount, keyword_init: true)
5
- User = Struct.new(:avatar_url, :name, :username, :commits, :github_url, keyword_init: true)
4
+ Invoice = Struct.new(:identifier, :status, :method, :amount)
5
+ User = Struct.new(:avatar_url, :name, :username, :commits, :github_url)
6
6
 
7
7
  def view_template
8
8
  component = "Table"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Views::Docs::Tabs < Views::Base
4
- Repo = Struct.new(:github_url, :name, :stars, :version, keyword_init: true)
4
+ Repo = Struct.new(:github_url, :name, :stars, :version)
5
5
 
6
6
  def view_template
7
7
  component = "Tabs"
@@ -2,20 +2,24 @@
2
2
 
3
3
  module RubyUI
4
4
  class TabsTrigger < Base
5
- def initialize(value:, **attrs)
5
+ def initialize(value:, as: :button, **attrs)
6
6
  @value = value
7
+ @as = as
7
8
  super(**attrs)
8
9
  end
9
10
 
10
11
  def view_template(&)
11
- button(**attrs, &)
12
+ if @as == :a
13
+ a(**attrs, &)
14
+ else
15
+ button(**attrs, &)
16
+ end
12
17
  end
13
18
 
14
19
  private
15
20
 
16
21
  def default_attrs
17
- {
18
- type: :button,
22
+ base = {
19
23
  data: {
20
24
  ruby_ui__tabs_target: "trigger",
21
25
  action: "click->ruby-ui--tabs#show",
@@ -29,6 +33,8 @@ module RubyUI
29
33
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
30
34
  ]
31
35
  }
36
+ base[:type] = :button unless @as == :a
37
+ base
32
38
  end
33
39
  end
34
40
  end
@@ -39,9 +39,13 @@ export default class extends Controller {
39
39
  dir: { type: String, default: "ltr" },
40
40
  }
41
41
 
42
- connect() {
42
+ initialize() {
43
+ this._expanded = false
43
44
  this._heights = new Map()
44
45
  this._resizeObservers = new WeakMap()
46
+ }
47
+
48
+ connect() {
45
49
  this._expanded = this.expandValue
46
50
  this._listEl = this.element.querySelector("ol") || (this.element.tagName === "OL" ? this.element : null)
47
51
  this._registerGlobalApi()
@@ -59,6 +63,8 @@ export default class extends Controller {
59
63
  this._listEl.addEventListener("pointerenter", this._onPointerEnter)
60
64
  this._listEl.addEventListener("pointerleave", this._onPointerLeave)
61
65
  document.addEventListener("keydown", this._onKey)
66
+
67
+ this._reflow()
62
68
  }
63
69
 
64
70
  disconnect() {
@@ -67,9 +73,21 @@ export default class extends Controller {
67
73
  this._listEl?.removeEventListener("pointerenter", this._onPointerEnter)
68
74
  this._listEl?.removeEventListener("pointerleave", this._onPointerLeave)
69
75
  document.removeEventListener("keydown", this._onKey)
76
+ this._listEl = null
77
+ }
78
+
79
+ positionValueChanged(value) {
80
+ this.element.setAttribute("data-position", value)
81
+ this._reflow()
82
+ }
83
+
84
+ expandValueChanged(value) {
85
+ this._expanded = value
86
+ this._reflow()
70
87
  }
71
88
 
72
89
  toastTargetConnected(el) {
90
+ this._resizeObservers.get(el)?.disconnect()
73
91
  if (typeof ResizeObserver !== "undefined") {
74
92
  const ro = new ResizeObserver(() => {
75
93
  this._heights.set(el, el.offsetHeight)
@@ -90,13 +108,11 @@ export default class extends Controller {
90
108
  }
91
109
 
92
110
  _spawn(detail) {
111
+ if (!this._listEl) return null
93
112
  const variant = VARIANTS.includes(detail.variant) ? detail.variant : "default"
94
113
  const tpl = this._skeletonFor(variant)
95
114
  if (!tpl) return null
96
- if (detail.position) {
97
- this.element.setAttribute("data-position", detail.position)
98
- this.positionValue = detail.position
99
- }
115
+ if (detail.position) this.positionValue = detail.position
100
116
  const node = tpl.content.firstElementChild.cloneNode(true)
101
117
 
102
118
  node.id = detail.id || `toast-${this._uuid()}`
@@ -151,7 +167,7 @@ export default class extends Controller {
151
167
  )
152
168
  return
153
169
  }
154
- const el = this._listEl.querySelector(`#${CSS.escape(id)}`)
170
+ const el = this._listEl?.querySelector(`#${CSS.escape(id)}`)
155
171
  if (el) el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true }))
156
172
  }
157
173
 
@@ -243,7 +259,7 @@ export default class extends Controller {
243
259
  if (wantCtrl !== e.ctrlKey) return
244
260
  if (wantMeta !== e.metaKey) return
245
261
  e.preventDefault()
246
- const first = this._listEl.firstElementChild
262
+ const first = this._listEl?.firstElementChild
247
263
  first?.focus()
248
264
  }
249
265
 
@@ -278,7 +294,7 @@ export default class extends Controller {
278
294
  }
279
295
 
280
296
  _mutate(id, variant, text) {
281
- const el = this._listEl.querySelector(`#${CSS.escape(id)}`)
297
+ const el = this._listEl?.querySelector(`#${CSS.escape(id)}`)
282
298
  if (!el) return
283
299
  el.dataset.variant = variant
284
300
  el.setAttribute("role", variant === "error" ? "alert" : "status")
data/lib/ruby_ui.rb CHANGED
@@ -3,5 +3,5 @@
3
3
  require "date"
4
4
 
5
5
  module RubyUI
6
- VERSION = "1.3.0"
6
+ VERSION = "1.6.0"
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - George Kettle
@@ -159,6 +159,11 @@ files:
159
159
  - lib/ruby_ui/breadcrumb/breadcrumb_list.rb
160
160
  - lib/ruby_ui/breadcrumb/breadcrumb_page.rb
161
161
  - lib/ruby_ui/breadcrumb/breadcrumb_separator.rb
162
+ - lib/ruby_ui/bubble/bubble.rb
163
+ - lib/ruby_ui/bubble/bubble_content.rb
164
+ - lib/ruby_ui/bubble/bubble_docs.rb
165
+ - lib/ruby_ui/bubble/bubble_group.rb
166
+ - lib/ruby_ui/bubble/bubble_reactions.rb
162
167
  - lib/ruby_ui/button/button.rb
163
168
  - lib/ruby_ui/button/button_docs.rb
164
169
  - lib/ruby_ui/calendar/calendar.rb
@@ -291,6 +296,13 @@ files:
291
296
  - lib/ruby_ui/dropdown_menu/dropdown_menu_label.rb
292
297
  - lib/ruby_ui/dropdown_menu/dropdown_menu_separator.rb
293
298
  - lib/ruby_ui/dropdown_menu/dropdown_menu_trigger.rb
299
+ - lib/ruby_ui/empty/empty.rb
300
+ - lib/ruby_ui/empty/empty_content.rb
301
+ - lib/ruby_ui/empty/empty_description.rb
302
+ - lib/ruby_ui/empty/empty_docs.rb
303
+ - lib/ruby_ui/empty/empty_header.rb
304
+ - lib/ruby_ui/empty/empty_media.rb
305
+ - lib/ruby_ui/empty/empty_title.rb
294
306
  - lib/ruby_ui/form/form.rb
295
307
  - lib/ruby_ui/form/form_docs.rb
296
308
  - lib/ruby_ui/form/form_field.rb
@@ -305,11 +317,32 @@ files:
305
317
  - lib/ruby_ui/hover_card/hover_card_trigger.rb
306
318
  - lib/ruby_ui/input/input.rb
307
319
  - lib/ruby_ui/input/input_docs.rb
320
+ - lib/ruby_ui/input_otp/input_otp.rb
321
+ - lib/ruby_ui/input_otp/input_otp_controller.js
322
+ - lib/ruby_ui/input_otp/input_otp_docs.rb
323
+ - lib/ruby_ui/input_otp/input_otp_group.rb
324
+ - lib/ruby_ui/input_otp/input_otp_separator.rb
325
+ - lib/ruby_ui/input_otp/input_otp_slot.rb
308
326
  - lib/ruby_ui/link/link.rb
309
327
  - lib/ruby_ui/link/link_docs.rb
310
328
  - lib/ruby_ui/masked_input/masked_input.rb
311
329
  - lib/ruby_ui/masked_input/masked_input_controller.js
312
330
  - lib/ruby_ui/masked_input/masked_input_docs.rb
331
+ - lib/ruby_ui/message/message.rb
332
+ - lib/ruby_ui/message/message_avatar.rb
333
+ - lib/ruby_ui/message/message_content.rb
334
+ - lib/ruby_ui/message/message_docs.rb
335
+ - lib/ruby_ui/message/message_footer.rb
336
+ - lib/ruby_ui/message/message_group.rb
337
+ - lib/ruby_ui/message/message_header.rb
338
+ - lib/ruby_ui/message_scroller/message_scroller.rb
339
+ - lib/ruby_ui/message_scroller/message_scroller_button.rb
340
+ - lib/ruby_ui/message_scroller/message_scroller_content.rb
341
+ - lib/ruby_ui/message_scroller/message_scroller_controller.js
342
+ - lib/ruby_ui/message_scroller/message_scroller_docs.rb
343
+ - lib/ruby_ui/message_scroller/message_scroller_item.rb
344
+ - lib/ruby_ui/message_scroller/message_scroller_provider.rb
345
+ - lib/ruby_ui/message_scroller/message_scroller_viewport.rb
313
346
  - lib/ruby_ui/native_select/native_select.rb
314
347
  - lib/ruby_ui/native_select/native_select_docs.rb
315
348
  - lib/ruby_ui/native_select/native_select_group.rb
@@ -453,7 +486,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
453
486
  - !ruby/object:Gem::Version
454
487
  version: '0'
455
488
  requirements: []
456
- rubygems_version: 4.0.10
489
+ rubygems_version: 3.6.9
457
490
  specification_version: 4
458
491
  summary: RubyUI is a UI Component Library for Ruby developers.
459
492
  test_files: []