ruby_ui 1.2.0 → 1.3.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/ruby_ui/dependencies.yml +32 -10
  3. data/lib/generators/ruby_ui/install/templates/tailwind.css.erb +1 -1
  4. data/lib/generators/ruby_ui/javascript_utils.rb +24 -7
  5. data/lib/ruby_ui/avatar/avatar.rb +3 -0
  6. data/lib/ruby_ui/avatar/avatar_controller.js +33 -0
  7. data/lib/ruby_ui/avatar/avatar_fallback.rb +3 -0
  8. data/lib/ruby_ui/avatar/avatar_image.rb +4 -0
  9. data/lib/ruby_ui/base.rb +6 -0
  10. data/lib/ruby_ui/calendar/calendar.rb +3 -1
  11. data/lib/ruby_ui/calendar/calendar_controller.js +66 -7
  12. data/lib/ruby_ui/calendar/calendar_days.rb +20 -0
  13. data/lib/ruby_ui/calendar/calendar_docs.rb +9 -0
  14. data/lib/ruby_ui/combobox/combobox.rb +1 -7
  15. data/lib/ruby_ui/combobox/combobox_checkbox.rb +7 -1
  16. data/lib/ruby_ui/combobox/combobox_controller.js +56 -244
  17. data/lib/ruby_ui/combobox/combobox_item.rb +7 -5
  18. data/lib/ruby_ui/combobox/combobox_list_group.rb +1 -1
  19. data/lib/ruby_ui/combobox/combobox_popover.rb +5 -0
  20. data/lib/ruby_ui/combobox/combobox_radio.rb +8 -1
  21. data/lib/ruby_ui/combobox/combobox_toggle_all_checkbox.rb +7 -1
  22. data/lib/ruby_ui/combobox/combobox_trigger.rb +19 -19
  23. data/lib/ruby_ui/command/command_controller.js +10 -19
  24. data/lib/ruby_ui/command/command_dialog.rb +4 -1
  25. data/lib/ruby_ui/command/command_dialog_content.rb +2 -2
  26. data/lib/ruby_ui/command/command_dialog_controller.js +34 -0
  27. data/lib/ruby_ui/command/command_dialog_trigger.rb +2 -2
  28. data/lib/ruby_ui/date_picker/date_picker.rb +85 -0
  29. data/lib/ruby_ui/date_picker/date_picker_docs.rb +23 -0
  30. data/lib/ruby_ui/masked_input/masked_input.rb +1 -11
  31. data/lib/ruby_ui/masked_input/masked_input_controller.js +0 -13
  32. data/lib/ruby_ui/select/select_value.rb +2 -1
  33. data/lib/ruby_ui/sheet/sheet.rb +9 -1
  34. data/lib/ruby_ui/sheet/sheet_controller.js +6 -0
  35. data/lib/ruby_ui/theme_toggle/theme_toggle.rb +14 -2
  36. data/lib/ruby_ui/theme_toggle/theme_toggle_controller.js +27 -19
  37. data/lib/ruby_ui/theme_toggle/theme_toggle_docs.rb +12 -42
  38. data/lib/ruby_ui/toast/toast.rb +18 -0
  39. data/lib/ruby_ui/toast/toast_action.rb +27 -0
  40. data/lib/ruby_ui/toast/toast_cancel.rb +27 -0
  41. data/lib/ruby_ui/toast/toast_close.rb +40 -0
  42. data/lib/ruby_ui/toast/toast_controller.js +151 -0
  43. data/lib/ruby_ui/toast/toast_description.rb +18 -0
  44. data/lib/ruby_ui/toast/toast_docs.rb +12 -0
  45. data/lib/ruby_ui/toast/toast_icon.rb +65 -0
  46. data/lib/ruby_ui/toast/toast_item.rb +72 -0
  47. data/lib/ruby_ui/toast/toast_region.rb +124 -0
  48. data/lib/ruby_ui/toast/toast_title.rb +18 -0
  49. data/lib/ruby_ui/toast/toaster_controller.js +306 -0
  50. data/lib/ruby_ui/toggle/toggle.rb +101 -0
  51. data/lib/ruby_ui/toggle/toggle_controller.js +33 -0
  52. data/lib/ruby_ui/toggle_group/toggle_group.rb +119 -0
  53. data/lib/ruby_ui/toggle_group/toggle_group_controller.js +126 -0
  54. data/lib/ruby_ui/toggle_group/toggle_group_item.rb +67 -0
  55. data/lib/ruby_ui/tooltip/tooltip_content.rb +12 -5
  56. data/lib/ruby_ui/tooltip/tooltip_controller.js +58 -22
  57. data/lib/ruby_ui/tooltip/tooltip_docs.rb +13 -0
  58. data/lib/ruby_ui/tooltip/tooltip_trigger.rb +10 -3
  59. data/lib/ruby_ui.rb +3 -1
  60. metadata +30 -14
  61. data/lib/ruby_ui/theme_toggle/set_dark_mode.rb +0 -16
  62. data/lib/ruby_ui/theme_toggle/set_light_mode.rb +0 -16
@@ -4,7 +4,8 @@ import { computePosition, autoUpdate, offset, flip } from "@floating-ui/dom";
4
4
  // Connects to data-controller="ruby-ui--combobox"
5
5
  export default class extends Controller {
6
6
  static values = {
7
- term: String
7
+ term: String,
8
+ minPopoverWidth: { type: Number, default: 240 }
8
9
  }
9
10
 
10
11
  static targets = [
@@ -15,276 +16,90 @@ export default class extends Controller {
15
16
  "emptyState",
16
17
  "searchInput",
17
18
  "trigger",
18
- "triggerContent",
19
- "badgeContainer",
20
- "clearButton",
21
- "badgeInput",
22
- "inputTrigger"
19
+ "triggerContent"
23
20
  ]
24
21
 
25
22
  selectedItemIndex = null
26
23
 
27
24
  connect() {
28
25
  this.updateTriggerContent()
29
- this.updateBadges()
30
- this.updateClearButton()
31
- this.updateInputTrigger()
32
-
33
- // Track mouse state to distinguish click-focus from tab-focus
34
- this._mouseDown = false
35
- this.element.addEventListener("mousedown", () => { this._mouseDown = true })
36
- this.element.addEventListener("mouseup", () => { setTimeout(() => { this._mouseDown = false }, 0) })
37
26
  }
38
27
 
39
28
  disconnect() {
40
29
  if (this.cleanup) { this.cleanup() }
41
30
  }
42
31
 
43
- // Popover
44
-
45
- togglePopover(event) {
46
- event.preventDefault()
47
-
48
- if (this.triggerTarget.ariaExpanded === "true") {
49
- this.closePopover()
50
- } else {
51
- this.openPopover(event)
52
- }
53
- }
54
-
55
- openPopover(event) {
56
- if (event && event.type !== "focusin" && event.type !== "focus") event.preventDefault()
57
-
58
- // focusin/focus: only open on keyboard focus (tab), not mouse click
59
- if (event && (event.type === "focusin" || event.type === "focus")) {
60
- if (this._mouseDown || this.triggerTarget.ariaExpanded === "true" || this._closingPopover) return
61
- }
62
-
63
- this.updatePopoverPosition()
64
- this.updatePopoverWidth()
65
- this.triggerTarget.ariaExpanded = "true"
66
- this.selectedItemIndex = null
67
- this.itemTargets.forEach(item => item.ariaCurrent = "false")
68
- this.popoverTarget.showPopover()
69
-
70
- // Always show all items on open; filter only on user typing
71
- this.applyFilter("")
72
-
73
- if (this.hasBadgeInputTarget) {
74
- this.badgeInputTarget.value = ""
75
- }
76
- }
77
-
78
- closePopover() {
79
- this._closingPopover = true
80
- this.triggerTarget.ariaExpanded = "false"
81
- this.popoverTarget.hidePopover()
82
- setTimeout(() => this._closingPopover = false, 200)
83
- }
84
-
85
32
  handlePopoverToggle(event) {
86
33
  // Keep ariaExpanded in sync with the actual popover state
87
34
  this.triggerTarget.ariaExpanded = event.newState === 'open' ? 'true' : 'false'
88
35
  }
89
36
 
90
- updatePopoverPosition() {
91
- this.cleanup = autoUpdate(this.triggerTarget, this.popoverTarget, () => {
92
- computePosition(this.triggerTarget, this.popoverTarget, {
93
- placement: 'bottom-start',
94
- middleware: [offset(4), flip()],
95
- }).then(({ x, y }) => {
96
- Object.assign(this.popoverTarget.style, {
97
- left: `${x}px`,
98
- top: `${y}px`,
99
- });
100
- });
101
- });
102
- }
103
-
104
- updatePopoverWidth() {
105
- this.popoverTarget.style.width = `${this.triggerTarget.offsetWidth}px`
106
- }
107
-
108
- // Selection
109
-
110
37
  inputChanged(e) {
111
38
  this.updateTriggerContent()
112
39
 
113
40
  if (e.target.type == "radio") {
114
41
  this.closePopover()
115
- this.updateInputTrigger()
116
42
  }
117
43
 
118
44
  if (this.hasToggleAllTarget && !e.target.checked) {
119
45
  this.toggleAllTarget.checked = false
120
46
  }
47
+ }
121
48
 
122
- this.updateBadges()
123
- this.updateClearButton()
49
+ inputContent(input) {
50
+ return input.dataset.text || input.parentElement.textContent
124
51
  }
125
52
 
126
53
  toggleAllItems() {
127
54
  const isChecked = this.toggleAllTarget.checked
128
55
  this.inputTargets.forEach(input => input.checked = isChecked)
129
56
  this.updateTriggerContent()
130
- this.updateBadges()
131
- this.updateClearButton()
132
- }
133
-
134
- clearAll(event) {
135
- if (event) event.preventDefault()
136
-
137
- this.inputTargets.forEach(input => input.checked = false)
138
- this.updateBadges()
139
- this.updateClearButton()
140
- this.updateTriggerContent()
141
- this.updateInputTrigger()
142
- }
143
-
144
- removeBadge(event) {
145
- event.preventDefault()
146
- event.stopPropagation()
147
-
148
- const value = event.currentTarget.closest('[data-value]').dataset.value
149
- const input = this.inputTargets.find(input => input.value === value)
150
-
151
- if (input) {
152
- input.checked = false
153
- input.dispatchEvent(new Event("change", { bubbles: true }))
154
- }
155
- }
156
-
157
- // Display
158
-
159
- inputContent(input) {
160
- return input.dataset.text || input.parentElement.textContent
161
57
  }
162
58
 
163
59
  updateTriggerContent() {
164
- if (!this.hasTriggerContentTarget) return
165
-
166
60
  const checkedInputs = this.inputTargets.filter(input => input.checked)
167
61
 
168
62
  if (checkedInputs.length === 0) {
169
63
  this.triggerContentTarget.innerText = this.triggerTarget.dataset.placeholder
170
- this.triggerContentTarget.classList.add("text-muted-foreground")
171
64
  } else if (this.termValue && checkedInputs.length > 1) {
172
65
  this.triggerContentTarget.innerText = `${checkedInputs.length} ${this.termValue}`
173
- this.triggerContentTarget.classList.remove("text-muted-foreground")
174
66
  } else {
175
67
  this.triggerContentTarget.innerText = checkedInputs.map((input) => this.inputContent(input)).join(", ")
176
- this.triggerContentTarget.classList.remove("text-muted-foreground")
177
68
  }
178
69
  }
179
70
 
180
- updateInputTrigger() {
181
- if (!this.hasInputTriggerTarget) return
182
- const checked = this.inputTargets.find(i => i.checked)
183
- this.inputTriggerTarget.value = checked ? this.inputContent(checked) : ""
184
- }
185
-
186
- // NOTE: badge classes mirror ComboboxBadge Ruby component. Update both if styles change.
187
- updateBadges() {
188
- if (!this.hasBadgeContainerTarget) return
189
-
190
- // Remove existing badges
191
- this.triggerTarget.querySelectorAll("[data-combobox-badge]").forEach(el => el.remove())
192
-
193
- const checkedInputs = this.inputTargets.filter(input => input.checked)
194
-
195
- // Toggle trigger height: h-9 when empty, h-auto min-h-9 when badges exist
196
- if (checkedInputs.length > 0) {
197
- this.triggerTarget.classList.remove("h-9")
198
- this.triggerTarget.classList.add("h-auto", "min-h-9")
199
- } else {
200
- this.triggerTarget.classList.remove("h-auto", "min-h-9", "pt-1.5")
201
- this.triggerTarget.classList.add("h-9")
202
- }
203
-
204
- checkedInputs.forEach(input => {
205
- const badge = document.createElement("span")
206
- badge.setAttribute("data-combobox-badge", "")
207
- badge.className = "inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-xs font-medium text-secondary-foreground"
208
- badge.dataset.value = input.value
209
-
210
- badge.appendChild(document.createTextNode(this.inputContent(input).trim()))
211
-
212
- const btn = document.createElement("button")
213
- btn.type = "button"
214
- btn.setAttribute("aria-label", "Remove")
215
- btn.className = "rounded-sm opacity-50 hover:opacity-100 focus-visible:outline-none"
216
-
217
- btn.addEventListener("click", (e) => {
218
- e.preventDefault()
219
- e.stopPropagation()
220
- e.stopImmediatePropagation()
221
- const target = this.inputTargets.find(i => i.value === input.value)
222
- if (target) {
223
- target.checked = false
224
- this.updateBadges()
225
- this.updateClearButton()
226
- }
227
- })
228
-
229
- const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
230
- svg.setAttribute("xmlns", "http://www.w3.org/2000/svg")
231
- svg.setAttribute("width", "12")
232
- svg.setAttribute("height", "12")
233
- svg.setAttribute("viewBox", "0 0 24 24")
234
- svg.setAttribute("fill", "none")
235
- svg.setAttribute("stroke", "currentColor")
236
- svg.setAttribute("stroke-width", "2")
237
- svg.setAttribute("stroke-linecap", "round")
238
- svg.setAttribute("stroke-linejoin", "round")
239
- svg.classList.add("pointer-events-none")
240
-
241
- const path1 = document.createElementNS("http://www.w3.org/2000/svg", "path")
242
- path1.setAttribute("d", "M18 6 6 18")
243
- const path2 = document.createElementNS("http://www.w3.org/2000/svg", "path")
244
- path2.setAttribute("d", "m6 6 12 12")
245
-
246
- svg.appendChild(path1)
247
- svg.appendChild(path2)
248
- btn.appendChild(svg)
249
- badge.appendChild(btn)
250
-
251
- // Insert badge directly in trigger, before the text input
252
- this.badgeInputTarget.insertAdjacentElement("beforebegin", badge)
253
- })
71
+ togglePopover(event) {
72
+ event.preventDefault()
254
73
 
255
- // Add top padding only when badges wrap to multiple lines
256
- // Class "pt-1.5" is referenced in ComboboxBadgeTrigger for Tailwind to compile it
257
- const badges = this.triggerTarget.querySelectorAll("[data-combobox-badge]")
258
- if (badges.length > 0 && this.badgeInputTarget.offsetTop > badges[0].offsetTop) {
259
- this.triggerTarget.classList.add("pt-1.5")
74
+ if (this.triggerTarget.ariaExpanded === "true") {
75
+ this.closePopover()
260
76
  } else {
261
- this.triggerTarget.classList.remove("pt-1.5")
77
+ this.openPopover(event)
262
78
  }
263
79
  }
264
80
 
265
- updateClearButton() {
266
- if (!this.hasClearButtonTarget) return
81
+ openPopover(event) {
82
+ if (event) event.preventDefault()
267
83
 
268
- const hasChecked = this.inputTargets.some(input => input.checked)
269
- this.clearButtonTarget.classList.toggle("hidden", !hasChecked)
84
+ this.updatePopoverPosition()
85
+ this.updatePopoverWidth()
86
+ this.triggerTarget.ariaExpanded = "true"
87
+ this.selectedItemIndex = null
88
+ this.itemTargets.forEach(item => item.ariaCurrent = "false")
89
+ this.popoverTarget.showPopover()
270
90
  }
271
91
 
272
- // Filter
92
+ closePopover() {
93
+ this.triggerTarget.ariaExpanded = "false"
94
+ this.popoverTarget.hidePopover()
95
+ }
273
96
 
274
97
  filterItems(e) {
275
- if (["ArrowDown", "ArrowUp", "Tab", "Enter"].includes(e.key)) return
276
-
277
- const term = this.hasBadgeInputTarget
278
- ? this.badgeInputTarget.value
279
- : this.hasInputTriggerTarget
280
- ? this.inputTriggerTarget.value
281
- : this.searchInputTarget.value
282
-
283
- this.applyFilter(term)
284
- }
98
+ if (["ArrowDown", "ArrowUp", "Tab", "Enter"].includes(e.key)) {
99
+ return
100
+ }
285
101
 
286
- applyFilter(term) {
287
- const filterTerm = term.toLowerCase()
102
+ const filterTerm = this.searchInputTarget.value.toLowerCase()
288
103
 
289
104
  if (this.hasToggleAllTarget) {
290
105
  if (filterTerm) this.toggleAllTarget.parentElement.classList.add("hidden")
@@ -292,10 +107,12 @@ export default class extends Controller {
292
107
  }
293
108
 
294
109
  let resultCount = 0
110
+
295
111
  this.selectedItemIndex = null
296
112
 
297
113
  this.inputTargets.forEach((input) => {
298
114
  const text = this.inputContent(input).toLowerCase()
115
+
299
116
  if (text.indexOf(filterTerm) > -1) {
300
117
  input.parentElement.classList.remove("hidden")
301
118
  resultCount++
@@ -305,20 +122,9 @@ export default class extends Controller {
305
122
  })
306
123
 
307
124
  this.emptyStateTarget.classList.toggle("hidden", resultCount !== 0)
308
-
309
- // Auto-highlight first visible result (without scrolling to avoid page jump)
310
- this.itemTargets.forEach(item => item.ariaCurrent = "false")
311
- const firstVisible = this.inputTargets.find(i => !i.parentElement.classList.contains("hidden"))
312
- if (firstVisible) {
313
- this.selectedItemIndex = 0
314
- firstVisible.parentElement.ariaCurrent = "true"
315
- }
316
125
  }
317
126
 
318
- // Keyboard
319
-
320
- keyDownPressed(event) {
321
- event.preventDefault()
127
+ keyDownPressed() {
322
128
  if (this.selectedItemIndex !== null) {
323
129
  this.selectedItemIndex++
324
130
  } else {
@@ -328,8 +134,7 @@ export default class extends Controller {
328
134
  this.focusSelectedInput()
329
135
  }
330
136
 
331
- keyUpPressed(event) {
332
- event.preventDefault()
137
+ keyUpPressed() {
333
138
  if (this.selectedItemIndex !== null) {
334
139
  this.selectedItemIndex--
335
140
  } else {
@@ -339,15 +144,6 @@ export default class extends Controller {
339
144
  this.focusSelectedInput()
340
145
  }
341
146
 
342
- keyEnterPressed(event) {
343
- event.preventDefault()
344
- const option = this.itemTargets.find(item => item.ariaCurrent === "true")
345
-
346
- if (option) {
347
- option.click()
348
- }
349
- }
350
-
351
147
  focusSelectedInput() {
352
148
  const visibleInputs = this.inputTargets.filter(input => !input.parentElement.classList.contains("hidden"))
353
149
 
@@ -363,19 +159,35 @@ export default class extends Controller {
363
159
  })
364
160
  }
365
161
 
162
+ keyEnterPressed(event) {
163
+ event.preventDefault()
164
+ const option = this.itemTargets.find(item => item.ariaCurrent === "true")
165
+
166
+ if (option) {
167
+ option.click()
168
+ }
169
+ }
170
+
366
171
  wrapSelectedInputIndex(length) {
367
172
  this.selectedItemIndex = ((this.selectedItemIndex % length) + length) % length
368
173
  }
369
174
 
370
- handleBadgeInputBackspace(event) {
371
- if (this.badgeInputTarget.value !== "") return
372
-
373
- const checkedInputs = this.inputTargets.filter(input => input.checked)
374
- const lastChecked = checkedInputs[checkedInputs.length - 1]
175
+ updatePopoverPosition() {
176
+ this.cleanup = autoUpdate(this.triggerTarget, this.popoverTarget, () => {
177
+ computePosition(this.triggerTarget, this.popoverTarget, {
178
+ placement: 'bottom-start',
179
+ middleware: [offset(4), flip()],
180
+ }).then(({ x, y }) => {
181
+ Object.assign(this.popoverTarget.style, {
182
+ left: `${x}px`,
183
+ top: `${y}px`,
184
+ });
185
+ });
186
+ });
187
+ }
375
188
 
376
- if (lastChecked) {
377
- lastChecked.checked = false
378
- lastChecked.dispatchEvent(new Event("change", { bubbles: true }))
379
- }
189
+ updatePopoverWidth() {
190
+ const width = Math.max(this.triggerTarget.offsetWidth, this.minPopoverWidthValue)
191
+ this.popoverTarget.style.width = `${width}px`
380
192
  }
381
193
  }
@@ -3,17 +3,19 @@
3
3
  module RubyUI
4
4
  class ComboboxItem < Base
5
5
  def view_template(&)
6
- label(**attrs) do
7
- yield if block_given?
8
- render ComboboxItemIndicator.new
9
- end
6
+ label(**attrs, &)
10
7
  end
11
8
 
12
9
  private
13
10
 
14
11
  def default_attrs
15
12
  {
16
- class: "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground aria-[current=true]:bg-accent has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed",
13
+ class: [
14
+ "flex flex-row w-full text-wrap [&>span,&>div]:truncate gap-2 items-center rounded-sm px-2 py-1 text-sm outline-none cursor-pointer",
15
+ "select-none has-[:checked]:bg-accent hover:bg-accent p-2",
16
+ "[&>svg]:pointer-events-none [&>svg]:size-4 [&>svg]:shrink-0 aria-[current=true]:bg-accent aria-[current=true]:ring aria-[current=true]:ring-offset-2",
17
+ "has-disabled:opacity-50 has-disabled:cursor-not-allowed"
18
+ ],
17
19
  role: "option",
18
20
  data: {
19
21
  ruby_ui__combobox_target: "item"
@@ -12,7 +12,7 @@ module RubyUI
12
12
 
13
13
  def default_attrs
14
14
  {
15
- class: ["hidden has-[label:not(.hidden)]:flex flex-col py-1 gap-1", LABEL_CLASSES],
15
+ class: ["hidden has-[label:not(.hidden)]:flex flex-col py-1 gap-1 border-b", LABEL_CLASSES],
16
16
  role: "group"
17
17
  }
18
18
  end
@@ -12,11 +12,16 @@ module RubyUI
12
12
  {
13
13
  class: "inset-auto m-0 absolute border bg-background shadow-lg rounded-lg",
14
14
  role: "popover",
15
+ autofocus: true,
15
16
  popover: true,
16
17
  data: {
17
18
  ruby_ui__combobox_target: "popover",
18
19
  action: %w[
19
20
  toggle->ruby-ui--combobox#handlePopoverToggle
21
+ keydown.down->ruby-ui--combobox#keyDownPressed
22
+ keydown.up->ruby-ui--combobox#keyUpPressed
23
+ keydown.enter->ruby-ui--combobox#keyEnterPressed
24
+ keydown.esc->ruby-ui--combobox#closePopover:prevent
20
25
  resize@window->ruby-ui--combobox#updatePopoverWidth
21
26
  ]
22
27
  }
@@ -10,7 +10,14 @@ module RubyUI
10
10
 
11
11
  def default_attrs
12
12
  {
13
- class: "peer sr-only",
13
+ class: [
14
+ "aspect-square h-4 w-4 rounded-full border border-primary accent-primary text-primary shadow",
15
+ "focus:outline-none",
16
+ "focus-visible:ring-1 focus-visible:ring-ring",
17
+ "disabled:cursor-not-allowed disabled:opacity-50",
18
+ "checked:bg-primary checked:text-primary-foreground",
19
+ "aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:pointer-events-none"
20
+ ],
14
21
  data: {
15
22
  ruby_ui__combobox_target: "input",
16
23
  ruby_ui__form_field_target: "input",
@@ -10,7 +10,13 @@ module RubyUI
10
10
 
11
11
  def default_attrs
12
12
  {
13
- class: "peer sr-only disabled:cursor-not-allowed",
13
+ class: [
14
+ "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background accent-primary",
15
+ "disabled:cursor-not-allowed disabled:opacity-50",
16
+ "checked:bg-primary checked:text-primary-foreground",
17
+ "aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:pointer-events-none",
18
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
19
+ ],
14
20
  data: {
15
21
  ruby_ui__combobox_target: "toggleAll",
16
22
  action: "change->ruby-ui--combobox#toggleAllItems"
@@ -9,7 +9,7 @@ module RubyUI
9
9
 
10
10
  def view_template
11
11
  button(**attrs) do
12
- span(class: "truncate text-muted-foreground", data: {ruby_ui__combobox_target: "triggerContent"}) do
12
+ span(class: "truncate", data: {ruby_ui__combobox_target: "triggerContent"}) do
13
13
  @placeholder
14
14
  end
15
15
  icon
@@ -26,13 +26,12 @@ module RubyUI
26
26
  "hover:bg-accent hover:text-accent-foreground",
27
27
  "disabled:pointer-events-none disabled:opacity-50",
28
28
  "aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
29
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
30
- "aria-invalid:border-destructive"
29
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
31
30
  ],
32
31
  data: {
33
32
  placeholder: @placeholder,
34
33
  ruby_ui__combobox_target: "trigger",
35
- action: "click->ruby-ui--combobox#togglePopover focus->ruby-ui--combobox#openPopover"
34
+ action: "ruby-ui--combobox#togglePopover"
36
35
  },
37
36
  aria: {
38
37
  haspopup: "listbox",
@@ -42,21 +41,22 @@ module RubyUI
42
41
  end
43
42
 
44
43
  def icon
45
- span(class: "shrink-0 flex items-center justify-center size-6 rounded-sm hover:bg-muted hover:text-foreground") do
46
- svg(
47
- xmlns: "http://www.w3.org/2000/svg",
48
- width: "24",
49
- height: "24",
50
- viewbox: "0 0 24 24",
51
- fill: "none",
52
- stroke: "currentColor",
53
- stroke_width: "2",
54
- stroke_linecap: "round",
55
- stroke_linejoin: "round",
56
- class: "pointer-events-none size-4 text-muted-foreground"
57
- ) do |s|
58
- s.path(d: "m6 9 6 6 6-6")
59
- end
44
+ svg(
45
+ xmlns: "http://www.w3.org/2000/svg",
46
+ viewbox: "0 0 24 24",
47
+ fill: "none",
48
+ stroke: "currentColor",
49
+ class: "ml-2 h-4 w-4 shrink-0 opacity-50",
50
+ stroke_width: "2",
51
+ stroke_linecap: "round",
52
+ stroke_linejoin: "round"
53
+ ) do |s|
54
+ s.path(
55
+ d: "m7 15 5 5 5-5"
56
+ )
57
+ s.path(
58
+ d: "m7 9 5-5 5 5"
59
+ )
60
60
  end
61
61
  end
62
62
  end
@@ -3,31 +3,18 @@ import Fuse from "fuse.js";
3
3
 
4
4
  // Connects to data-controller="ruby-ui--command"
5
5
  export default class extends Controller {
6
- static targets = ["input", "group", "item", "empty", "content"];
7
-
8
- static values = {
9
- open: {
10
- type: Boolean,
11
- default: false,
12
- },
13
- };
6
+ static targets = ["input", "group", "item", "empty"];
14
7
 
15
8
  connect() {
16
- this.inputTarget.focus();
17
- this.searchIndex = this.buildSearchIndex();
18
- this.toggleVisibility(this.emptyTargets, false);
19
9
  this.selectedIndex = -1;
20
10
 
21
- if (this.openValue) {
22
- this.open();
11
+ if (!this.hasInputTarget) {
12
+ return;
23
13
  }
24
- }
25
14
 
26
- open(e) {
27
- e.preventDefault();
28
- document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML);
29
- // prevent scroll on body
30
- document.body.classList.add("overflow-hidden");
15
+ this.inputTarget.focus();
16
+ this.searchIndex = this.buildSearchIndex();
17
+ this.toggleVisibility(this.emptyTargets, false);
31
18
  }
32
19
 
33
20
  dismiss() {
@@ -37,6 +24,10 @@ export default class extends Controller {
37
24
  this.element.remove();
38
25
  }
39
26
 
27
+ focusInput() {
28
+ this.inputTarget?.focus();
29
+ }
30
+
40
31
  filter(e) {
41
32
  // Deselect any previously selected item
42
33
  this.deselectAll();
@@ -10,7 +10,10 @@ module RubyUI
10
10
 
11
11
  def default_attrs
12
12
  {
13
- data: {controller: "ruby-ui--command"}
13
+ data: {
14
+ controller: "ruby-ui--command-dialog",
15
+ ruby_ui__command_dialog_ruby_ui__command_outlet: "[data-ruby-ui--command-dialog-instance]"
16
+ }
14
17
  }
15
18
  end
16
19
  end
@@ -17,8 +17,8 @@ module RubyUI
17
17
  end
18
18
 
19
19
  def view_template(&block)
20
- template(data: {ruby_ui__command_target: "content"}) do
21
- div(data: {controller: "ruby-ui--command"}) do
20
+ template(data: {ruby_ui__command_dialog_target: "content"}) do
21
+ div(data: {controller: "ruby-ui--command", ruby_ui__command_dialog_instance: true}) do
22
22
  backdrop
23
23
  div(**attrs, &block)
24
24
  end
@@ -0,0 +1,34 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // Connects to data-controller="ruby-ui--command-dialog"
4
+ export default class extends Controller {
5
+ static targets = ["content"];
6
+ static outlets = ["ruby-ui--command"];
7
+
8
+ rubyUiCommandOutletConnected(controller) {
9
+ this.openOutlet = controller;
10
+ }
11
+
12
+ rubyUiCommandOutletDisconnected() {
13
+ this.openOutlet = null;
14
+ }
15
+
16
+ open(e) {
17
+ if (e) {
18
+ e.preventDefault();
19
+ }
20
+
21
+ if (!this.hasContentTarget) {
22
+ return;
23
+ }
24
+
25
+ if (this.openOutlet) {
26
+ this.openOutlet.focusInput();
27
+ return;
28
+ }
29
+
30
+ document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML);
31
+ // prevent scroll on body
32
+ document.body.classList.add("overflow-hidden");
33
+ }
34
+ }