ruby_ui 1.1.0 → 1.2.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/ruby_ui/component_generator.rb +5 -1
  3. data/lib/generators/ruby_ui/dependencies.yml +10 -0
  4. data/lib/generators/ruby_ui/install/install_generator.rb +1 -1
  5. data/lib/generators/ruby_ui/javascript_utils.rb +4 -0
  6. data/lib/ruby_ui/combobox/combobox.rb +7 -1
  7. data/lib/ruby_ui/combobox/combobox_badge.rb +17 -0
  8. data/lib/ruby_ui/combobox/combobox_badge_trigger.rb +47 -0
  9. data/lib/ruby_ui/combobox/combobox_checkbox.rb +1 -7
  10. data/lib/ruby_ui/combobox/combobox_clear_button.rb +40 -0
  11. data/lib/ruby_ui/combobox/combobox_controller.js +243 -53
  12. data/lib/ruby_ui/combobox/combobox_docs.rb +199 -64
  13. data/lib/ruby_ui/combobox/combobox_input_trigger.rb +64 -0
  14. data/lib/ruby_ui/combobox/combobox_item.rb +5 -7
  15. data/lib/ruby_ui/combobox/combobox_item_indicator.rb +30 -0
  16. data/lib/ruby_ui/combobox/combobox_list_group.rb +1 -1
  17. data/lib/ruby_ui/combobox/combobox_popover.rb +0 -5
  18. data/lib/ruby_ui/combobox/combobox_radio.rb +1 -8
  19. data/lib/ruby_ui/combobox/combobox_toggle_all_checkbox.rb +1 -7
  20. data/lib/ruby_ui/combobox/combobox_trigger.rb +19 -19
  21. data/lib/ruby_ui/data_table/data_table.rb +29 -0
  22. data/lib/ruby_ui/data_table/data_table_bulk_actions.rb +18 -0
  23. data/lib/ruby_ui/data_table/data_table_column_toggle.rb +62 -0
  24. data/lib/ruby_ui/data_table/data_table_column_visibility_controller.js +14 -0
  25. data/lib/ruby_ui/data_table/data_table_controller.js +57 -0
  26. data/lib/ruby_ui/data_table/data_table_docs.rb +180 -0
  27. data/lib/ruby_ui/data_table/data_table_expand_toggle.rb +53 -0
  28. data/lib/ruby_ui/data_table/data_table_form.rb +39 -0
  29. data/lib/ruby_ui/data_table/data_table_kaminari_adapter.rb +17 -0
  30. data/lib/ruby_ui/data_table/data_table_manual_adapter.rb +17 -0
  31. data/lib/ruby_ui/data_table/data_table_pagination.rb +100 -0
  32. data/lib/ruby_ui/data_table/data_table_pagination_bar.rb +15 -0
  33. data/lib/ruby_ui/data_table/data_table_pagy_adapter.rb +17 -0
  34. data/lib/ruby_ui/data_table/data_table_per_page_select.rb +35 -0
  35. data/lib/ruby_ui/data_table/data_table_row_checkbox.rb +30 -0
  36. data/lib/ruby_ui/data_table/data_table_search.rb +57 -0
  37. data/lib/ruby_ui/data_table/data_table_search_controller.js +62 -0
  38. data/lib/ruby_ui/data_table/data_table_select_all_checkbox.rb +21 -0
  39. data/lib/ruby_ui/data_table/data_table_selection_summary.rb +25 -0
  40. data/lib/ruby_ui/data_table/data_table_sort_head.rb +112 -0
  41. data/lib/ruby_ui/data_table/data_table_toolbar.rb +15 -0
  42. data/lib/ruby_ui/masked_input/masked_input.rb +11 -1
  43. data/lib/ruby_ui/masked_input/masked_input_controller.js +13 -0
  44. data/lib/ruby_ui/native_select/native_select.rb +39 -0
  45. data/lib/ruby_ui/native_select/native_select_docs.rb +83 -0
  46. data/lib/ruby_ui/native_select/native_select_group.rb +15 -0
  47. data/lib/ruby_ui/native_select/native_select_icon.rb +39 -0
  48. data/lib/ruby_ui/native_select/native_select_option.rb +15 -0
  49. data/lib/ruby_ui.rb +1 -1
  50. metadata +43 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae8ccd52ccc249a9a1a409facc1883da7f87170bc5f70a0df32755e2fc5e5577
4
- data.tar.gz: df99edbcb78e881e98a114c58b1e0e771180d40acc0677ab182c50e29487d33d
3
+ metadata.gz: 488fdc5b97a189ce4916f776af376a06760255437f681b3e3ef7e9aa0998e2b3
4
+ data.tar.gz: ff44a35b46e45d1cf1b510716fdc174fc50e229ef7a82d81c8318d51911beb3d
5
5
  SHA512:
6
- metadata.gz: 9509f7c13c94b9f1b4767cc194e7eaeecda6ffd8eeb872c97693e7cf9ac875dbefa1457edc882df7e2ac093facf77ff856f1af3191549a7f37be8d901cb00ec6
7
- data.tar.gz: 44f479fb9b95d79318e358dc4b2864a5b13cc51d1ddd958a199cd6c184ed190de8d93a07e07d416ca2d5c89e23094f1b158c187048b3030d17eadca9b23eb065
6
+ metadata.gz: 7f7fe5941b4a9e4375d280efd31ab1e35dca88b699cf4580f6722294c4b8fe161240bbe7bce09bf5f75d5a41a9c2d62e6c24ab9a3b4d4f32ef8062fa4e81d5b3
7
+ data.tar.gz: 0b3e785f7380c7ba5abc0e721d5d17c3a5708a554b13bb2cd946e8019241642ebd7dfb1b3cfc46b759d14e18e37d82cbf0f874824a0489ddf8d09003bda1d9d1
@@ -9,6 +9,7 @@ module RubyUI
9
9
  source_root File.expand_path("../../ruby_ui", __dir__)
10
10
  argument :component_name, type: :string, required: true
11
11
  class_option :force, type: :boolean, default: false
12
+ class_option :with_docs, type: :boolean, default: false
12
13
 
13
14
  def generate_component
14
15
  if component_not_found?
@@ -63,7 +64,10 @@ module RubyUI
63
64
 
64
65
  def component_folder_path = File.join(self.class.source_root, component_folder_name)
65
66
 
66
- def components_file_paths = Dir.glob(File.join(component_folder_path, "*.rb"))
67
+ def components_file_paths
68
+ files = Dir.glob(File.join(component_folder_path, "*.rb"))
69
+ options["with_docs"] ? files : files.reject { |f| f.end_with?("_docs.rb") }
70
+ end
67
71
 
68
72
  def js_controller_file_paths = Dir.glob(File.join(component_folder_path, "*.js"))
69
73
 
@@ -2,6 +2,16 @@ accordion:
2
2
  js_packages:
3
3
  - "motion"
4
4
 
5
+ data_table:
6
+ components:
7
+ - "Table"
8
+ - "Checkbox"
9
+ - "NativeSelect"
10
+ - "Pagination"
11
+ - "DropdownMenu"
12
+ - "Input"
13
+ - "Button"
14
+
5
15
  alert_dialog:
6
16
  components:
7
17
  - "Button"
@@ -30,7 +30,7 @@ module RubyUI
30
30
  if gem_installed?("tailwind_merge")
31
31
  say "tailwind_merge is already installed", :green
32
32
  else
33
- say "Adding phlex-rails to Gemfile"
33
+ say "Adding tailwind_merge to Gemfile"
34
34
  run %(bundle add tailwind_merge)
35
35
  end
36
36
  end
@@ -4,6 +4,8 @@ module RubyUI
4
4
  def install_js_package(package)
5
5
  if using_importmap?
6
6
  pin_with_importmap(package)
7
+ elsif using_bun?
8
+ run "bun add #{package}"
7
9
  elsif using_yarn?
8
10
  run "yarn add #{package}"
9
11
  elsif using_npm?
@@ -30,6 +32,8 @@ module RubyUI
30
32
  File.exist?(Rails.root.join("config/importmap.rb")) && File.exist?(Rails.root.join("bin/importmap"))
31
33
  end
32
34
 
35
+ def using_bun? = File.exist?(Rails.root.join("bun.lock"))
36
+
33
37
  def using_npm? = File.exist?(Rails.root.join("package-lock.json"))
34
38
 
35
39
  def using_pnpm? = File.exist?(Rails.root.join("pnpm-lock.yaml"))
@@ -19,7 +19,13 @@ module RubyUI
19
19
  data: {
20
20
  controller: "ruby-ui--combobox",
21
21
  ruby_ui__combobox_term_value: @term,
22
- action: "turbo:morph@window->ruby-ui--combobox#updateTriggerContent"
22
+ action: %w[
23
+ turbo:morph@window->ruby-ui--combobox#updateTriggerContent
24
+ keydown.down->ruby-ui--combobox#keyDownPressed
25
+ keydown.up->ruby-ui--combobox#keyUpPressed
26
+ keydown.enter->ruby-ui--combobox#keyEnterPressed
27
+ keydown.esc->ruby-ui--combobox#closePopover:prevent
28
+ ]
23
29
  }
24
30
  }
25
31
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class ComboboxBadge < Base
5
+ def view_template(&)
6
+ span(**attrs, &)
7
+ end
8
+
9
+ private
10
+
11
+ def default_attrs
12
+ {
13
+ class: "inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-xs font-medium text-secondary-foreground"
14
+ }
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class ComboboxBadgeTrigger < Base
5
+ def initialize(placeholder: "", clear_button: false, **)
6
+ @placeholder = placeholder
7
+ @clear_button = clear_button
8
+ super(**)
9
+ end
10
+
11
+ def view_template(&)
12
+ div(**attrs) do
13
+ div(data: {ruby_ui__combobox_target: "badgeContainer"}, class: "hidden")
14
+ input(
15
+ type: "text",
16
+ class: "flex-1 min-w-8 bg-transparent border-0 px-0 outline-none focus:ring-0 placeholder:text-muted-foreground text-sm",
17
+ autocomplete: "off",
18
+ autocorrect: "off",
19
+ spellcheck: "false",
20
+ placeholder: @placeholder,
21
+ data: {
22
+ ruby_ui__combobox_target: "badgeInput",
23
+ action: "keyup->ruby-ui--combobox#filterItems input->ruby-ui--combobox#filterItems keydown.backspace->ruby-ui--combobox#handleBadgeInputBackspace"
24
+ }
25
+ )
26
+ render ComboboxClearButton.new if @clear_button
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ # JS-toggled classes (referenced here so Tailwind compiles them): h-auto min-h-9 pt-1.5
33
+ def default_attrs
34
+ {
35
+ class: "flex h-9 w-full flex-wrap items-center gap-1 rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 cursor-text",
36
+ data: {
37
+ ruby_ui__combobox_target: "trigger",
38
+ action: "click->ruby-ui--combobox#openPopover focusin->ruby-ui--combobox#openPopover"
39
+ },
40
+ aria: {
41
+ haspopup: "listbox",
42
+ expanded: "false"
43
+ }
44
+ }
45
+ end
46
+ end
47
+ end
@@ -10,13 +10,7 @@ module RubyUI
10
10
 
11
11
  def default_attrs
12
12
  {
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
- ],
13
+ class: "peer sr-only",
20
14
  data: {
21
15
  ruby_ui__combobox_target: "input",
22
16
  action: "ruby-ui--combobox#inputChanged"
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class ComboboxClearButton < Base
5
+ def view_template
6
+ button(**attrs) do
7
+ svg(
8
+ xmlns: "http://www.w3.org/2000/svg",
9
+ width: "24",
10
+ height: "24",
11
+ viewbox: "0 0 24 24",
12
+ fill: "none",
13
+ stroke: "currentColor",
14
+ stroke_width: "2",
15
+ stroke_linecap: "round",
16
+ stroke_linejoin: "round",
17
+ class: "size-3.5"
18
+ ) do |s|
19
+ s.path(d: "M18 6 6 18")
20
+ s.path(d: "m6 6 12 12")
21
+ end
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def default_attrs
28
+ {
29
+ type: "button",
30
+ class: "ml-auto shrink-0 rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring hidden",
31
+ aria: {label: "Clear selection"},
32
+ data: {
33
+ ruby_ui__combobox_target: "clearButton",
34
+ # JS implementation in combobox_controller.js
35
+ action: "ruby-ui--combobox#clearAll"
36
+ }
37
+ }
38
+ end
39
+ end
40
+ end
@@ -15,90 +15,276 @@ export default class extends Controller {
15
15
  "emptyState",
16
16
  "searchInput",
17
17
  "trigger",
18
- "triggerContent"
18
+ "triggerContent",
19
+ "badgeContainer",
20
+ "clearButton",
21
+ "badgeInput",
22
+ "inputTrigger"
19
23
  ]
20
24
 
21
25
  selectedItemIndex = null
22
26
 
23
27
  connect() {
24
28
  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) })
25
37
  }
26
38
 
27
39
  disconnect() {
28
40
  if (this.cleanup) { this.cleanup() }
29
41
  }
30
42
 
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
+
31
85
  handlePopoverToggle(event) {
32
86
  // Keep ariaExpanded in sync with the actual popover state
33
87
  this.triggerTarget.ariaExpanded = event.newState === 'open' ? 'true' : 'false'
34
88
  }
35
89
 
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
+
36
110
  inputChanged(e) {
37
111
  this.updateTriggerContent()
38
112
 
39
113
  if (e.target.type == "radio") {
40
114
  this.closePopover()
115
+ this.updateInputTrigger()
41
116
  }
42
117
 
43
118
  if (this.hasToggleAllTarget && !e.target.checked) {
44
119
  this.toggleAllTarget.checked = false
45
120
  }
46
- }
47
121
 
48
- inputContent(input) {
49
- return input.dataset.text || input.parentElement.textContent
122
+ this.updateBadges()
123
+ this.updateClearButton()
50
124
  }
51
125
 
52
126
  toggleAllItems() {
53
127
  const isChecked = this.toggleAllTarget.checked
54
128
  this.inputTargets.forEach(input => input.checked = isChecked)
55
129
  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
56
161
  }
57
162
 
58
163
  updateTriggerContent() {
164
+ if (!this.hasTriggerContentTarget) return
165
+
59
166
  const checkedInputs = this.inputTargets.filter(input => input.checked)
60
167
 
61
168
  if (checkedInputs.length === 0) {
62
169
  this.triggerContentTarget.innerText = this.triggerTarget.dataset.placeholder
170
+ this.triggerContentTarget.classList.add("text-muted-foreground")
63
171
  } else if (this.termValue && checkedInputs.length > 1) {
64
172
  this.triggerContentTarget.innerText = `${checkedInputs.length} ${this.termValue}`
173
+ this.triggerContentTarget.classList.remove("text-muted-foreground")
65
174
  } else {
66
175
  this.triggerContentTarget.innerText = checkedInputs.map((input) => this.inputContent(input)).join(", ")
176
+ this.triggerContentTarget.classList.remove("text-muted-foreground")
67
177
  }
68
178
  }
69
179
 
70
- togglePopover(event) {
71
- event.preventDefault()
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
+ }
72
185
 
73
- if (this.triggerTarget.ariaExpanded === "true") {
74
- this.closePopover()
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")
75
199
  } else {
76
- this.openPopover(event)
200
+ this.triggerTarget.classList.remove("h-auto", "min-h-9", "pt-1.5")
201
+ this.triggerTarget.classList.add("h-9")
77
202
  }
78
- }
79
203
 
80
- openPopover(event) {
81
- if (event) event.preventDefault()
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
+ })
82
254
 
83
- this.updatePopoverPosition()
84
- this.updatePopoverWidth()
85
- this.triggerTarget.ariaExpanded = "true"
86
- this.selectedItemIndex = null
87
- this.itemTargets.forEach(item => item.ariaCurrent = "false")
88
- this.popoverTarget.showPopover()
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")
260
+ } else {
261
+ this.triggerTarget.classList.remove("pt-1.5")
262
+ }
89
263
  }
90
264
 
91
- closePopover() {
92
- this.triggerTarget.ariaExpanded = "false"
93
- this.popoverTarget.hidePopover()
265
+ updateClearButton() {
266
+ if (!this.hasClearButtonTarget) return
267
+
268
+ const hasChecked = this.inputTargets.some(input => input.checked)
269
+ this.clearButtonTarget.classList.toggle("hidden", !hasChecked)
94
270
  }
95
271
 
272
+ // Filter
273
+
96
274
  filterItems(e) {
97
- if (["ArrowDown", "ArrowUp", "Tab", "Enter"].includes(e.key)) {
98
- return
99
- }
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
+ }
100
285
 
101
- const filterTerm = this.searchInputTarget.value.toLowerCase()
286
+ applyFilter(term) {
287
+ const filterTerm = term.toLowerCase()
102
288
 
103
289
  if (this.hasToggleAllTarget) {
104
290
  if (filterTerm) this.toggleAllTarget.parentElement.classList.add("hidden")
@@ -106,12 +292,10 @@ export default class extends Controller {
106
292
  }
107
293
 
108
294
  let resultCount = 0
109
-
110
295
  this.selectedItemIndex = null
111
296
 
112
297
  this.inputTargets.forEach((input) => {
113
298
  const text = this.inputContent(input).toLowerCase()
114
-
115
299
  if (text.indexOf(filterTerm) > -1) {
116
300
  input.parentElement.classList.remove("hidden")
117
301
  resultCount++
@@ -121,9 +305,20 @@ export default class extends Controller {
121
305
  })
122
306
 
123
307
  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
+ }
124
316
  }
125
317
 
126
- keyDownPressed() {
318
+ // Keyboard
319
+
320
+ keyDownPressed(event) {
321
+ event.preventDefault()
127
322
  if (this.selectedItemIndex !== null) {
128
323
  this.selectedItemIndex++
129
324
  } else {
@@ -133,7 +328,8 @@ export default class extends Controller {
133
328
  this.focusSelectedInput()
134
329
  }
135
330
 
136
- keyUpPressed() {
331
+ keyUpPressed(event) {
332
+ event.preventDefault()
137
333
  if (this.selectedItemIndex !== null) {
138
334
  this.selectedItemIndex--
139
335
  } else {
@@ -143,6 +339,15 @@ export default class extends Controller {
143
339
  this.focusSelectedInput()
144
340
  }
145
341
 
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
+
146
351
  focusSelectedInput() {
147
352
  const visibleInputs = this.inputTargets.filter(input => !input.parentElement.classList.contains("hidden"))
148
353
 
@@ -158,34 +363,19 @@ export default class extends Controller {
158
363
  })
159
364
  }
160
365
 
161
- keyEnterPressed(event) {
162
- event.preventDefault()
163
- const option = this.itemTargets.find(item => item.ariaCurrent === "true")
164
-
165
- if (option) {
166
- option.click()
167
- }
168
- }
169
-
170
366
  wrapSelectedInputIndex(length) {
171
367
  this.selectedItemIndex = ((this.selectedItemIndex % length) + length) % length
172
368
  }
173
369
 
174
- updatePopoverPosition() {
175
- this.cleanup = autoUpdate(this.triggerTarget, this.popoverTarget, () => {
176
- computePosition(this.triggerTarget, this.popoverTarget, {
177
- placement: 'bottom-start',
178
- middleware: [offset(4), flip()],
179
- }).then(({ x, y }) => {
180
- Object.assign(this.popoverTarget.style, {
181
- left: `${x}px`,
182
- top: `${y}px`,
183
- });
184
- });
185
- });
186
- }
370
+ handleBadgeInputBackspace(event) {
371
+ if (this.badgeInputTarget.value !== "") return
187
372
 
188
- updatePopoverWidth() {
189
- this.popoverTarget.style.width = `${this.triggerTarget.offsetWidth}px`
373
+ const checkedInputs = this.inputTargets.filter(input => input.checked)
374
+ const lastChecked = checkedInputs[checkedInputs.length - 1]
375
+
376
+ if (lastChecked) {
377
+ lastChecked.checked = false
378
+ lastChecked.dispatchEvent(new Event("change", { bubbles: true }))
379
+ }
190
380
  }
191
381
  }