shadcn-rails 0.1.0 → 0.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -1
  3. data/CLAUDE.md +151 -2
  4. data/PROGRESS.md +30 -20
  5. data/README.md +89 -1398
  6. data/Rakefile +66 -0
  7. data/__tests__/controllers/combobox_controller.test.js +56 -51
  8. data/__tests__/controllers/context_menu_controller.test.js +280 -2
  9. data/__tests__/controllers/menubar_controller.test.js +5 -4
  10. data/__tests__/controllers/navigation_menu_controller.test.js +5 -4
  11. data/__tests__/controllers/popover_controller.test.js +35 -60
  12. data/__tests__/controllers/select_controller.test.js +5 -1
  13. data/app/assets/javascripts/shadcn/controllers/base_menu_controller.js +266 -0
  14. data/app/assets/javascripts/shadcn/controllers/combobox_controller.js +13 -8
  15. data/app/assets/javascripts/shadcn/controllers/command_controller.js +5 -1
  16. data/app/assets/javascripts/shadcn/controllers/context_menu_controller.js +61 -105
  17. data/app/assets/javascripts/shadcn/controllers/dropdown_controller.js +49 -170
  18. data/app/assets/javascripts/shadcn/controllers/menubar_controller.js +10 -7
  19. data/app/assets/javascripts/shadcn/controllers/navigation_menu_controller.js +10 -6
  20. data/app/assets/javascripts/shadcn/controllers/popover_controller.js +7 -7
  21. data/app/assets/javascripts/shadcn/controllers/select_controller.js +12 -10
  22. data/app/assets/javascripts/shadcn/controllers/sidebar_controller.js +24 -14
  23. data/app/assets/javascripts/shadcn/index.js +2 -0
  24. data/app/assets/stylesheets/shadcn/components.css +12 -0
  25. data/app/components/shadcn/command_list_component.rb +29 -14
  26. data/app/components/shadcn/context_menu_checkbox_item_component.rb +76 -0
  27. data/app/components/shadcn/context_menu_content_component.rb +37 -14
  28. data/app/components/shadcn/context_menu_item_component.rb +3 -2
  29. data/app/components/shadcn/context_menu_radio_group_component.rb +42 -0
  30. data/app/components/shadcn/context_menu_radio_item_component.rb +76 -0
  31. data/app/components/shadcn/dropdown_menu_checkbox_item_component.rb +76 -0
  32. data/app/components/shadcn/dropdown_menu_content_component.rb +45 -16
  33. data/app/components/shadcn/dropdown_menu_radio_group_component.rb +42 -0
  34. data/app/components/shadcn/dropdown_menu_radio_item_component.rb +76 -0
  35. data/app/components/shadcn/menubar_content_component.rb +45 -20
  36. data/app/components/shadcn/menubar_sub_content_component.rb +21 -8
  37. data/app/components/shadcn/radio_group_item_component.rb +32 -6
  38. data/app/components/shadcn/resizable_panel_group_component.rb +27 -16
  39. data/app/components/shadcn/select_component.rb +23 -6
  40. data/bin/bump +321 -0
  41. data/bin/release +205 -0
  42. data/bin/test +75 -0
  43. data/jest.config.js +1 -1
  44. data/lib/shadcn/rails/version.rb +1 -1
  45. data/package-lock.json +27 -4
  46. data/package.json +4 -1
  47. metadata +11 -1
@@ -1,43 +1,65 @@
1
- import { Controller } from "@hotwired/stimulus"
1
+ import BaseMenuController from "./base_menu_controller"
2
2
 
3
3
  /**
4
4
  * Context Menu controller for right-click menus
5
- * Handles opening at mouse position, closing, keyboard navigation, and item selection
5
+ * Extends BaseMenuController with context menu-specific positioning and event handling
6
6
  */
7
- export default class extends Controller {
8
- static targets = ["trigger", "content", "item"]
7
+ export default class extends BaseMenuController {
8
+ static targets = [...BaseMenuController.targets]
9
9
  static values = {
10
- open: { type: Boolean, default: false }
10
+ ...BaseMenuController.values,
11
+ hideDelay: { type: Number, default: 100 }
11
12
  }
12
13
 
13
14
  connect() {
14
- this.focusedIndex = -1
15
- this.boundHandleClickOutside = this.handleClickOutside.bind(this)
16
- this.boundHandleKeydown = this.handleKeydown.bind(this)
15
+ super.connect()
16
+ this.boundHandleContextMenu = this.handleContextMenu.bind(this)
17
+ this.originalOverflow = null
18
+ this.mouseX = 0
19
+ this.mouseY = 0
20
+ this._ignoreClickOutside = false
17
21
  }
18
22
 
19
- disconnect() {
20
- this.hide()
23
+ // Override clickOutside to handle the deferred close behavior
24
+ // Context menus need to ignore clicks in the same frame as the right-click
25
+ clickOutside(event) {
26
+ if (this._ignoreClickOutside) return
27
+ super.clickOutside(event)
21
28
  }
22
29
 
23
30
  show(event) {
24
31
  event?.preventDefault()
25
32
 
33
+ // Cancel any pending hide timeout from a previous close
34
+ this.cancelHideTimeout()
35
+
26
36
  // Store mouse position for positioning
27
37
  this.mouseX = event?.clientX || 0
28
38
  this.mouseY = event?.clientY || 0
29
39
 
30
40
  this.openValue = true
31
41
 
42
+ // Lock scroll (only if not already locked)
43
+ if (document.body.style.overflow !== "hidden") {
44
+ this.originalOverflow = document.body.style.overflow
45
+ document.body.style.overflow = "hidden"
46
+ }
47
+
32
48
  if (this.hasContentTarget) {
33
49
  this.contentTarget.hidden = false
34
50
  this.contentTarget.dataset.state = "open"
35
51
  this.positionContent()
36
52
  }
37
53
 
38
- // Add event listeners
39
- document.addEventListener("click", this.boundHandleClickOutside)
40
- document.addEventListener("contextmenu", this.boundHandleClickOutside)
54
+ // Defer click outside detection to prevent immediate close from right-click
55
+ // The contextmenu event can sometimes trigger a click in the same event cycle
56
+ this._ignoreClickOutside = true
57
+ requestAnimationFrame(() => {
58
+ this._ignoreClickOutside = false
59
+ if (this.openValue) {
60
+ document.addEventListener("contextmenu", this.boundHandleContextMenu)
61
+ }
62
+ })
41
63
  document.addEventListener("keydown", this.boundHandleKeydown)
42
64
 
43
65
  // Focus first item
@@ -52,117 +74,51 @@ export default class extends Controller {
52
74
 
53
75
  this.openValue = false
54
76
 
77
+ // Remove event listeners immediately to prevent double-triggering
78
+ document.removeEventListener("contextmenu", this.boundHandleContextMenu)
79
+ document.removeEventListener("keydown", this.boundHandleKeydown)
80
+
55
81
  if (this.hasContentTarget) {
56
82
  this.contentTarget.dataset.state = "closed"
57
- // Hide after animation
58
- setTimeout(() => {
83
+ // Wait for animation to complete before hiding and restoring scroll
84
+ // Animation duration is 100ms, add buffer for smooth transition
85
+ this.hideTimeoutId = setTimeout(() => {
59
86
  if (!this.openValue) {
60
87
  this.contentTarget.hidden = true
88
+ // Restore scroll only after menu is fully hidden
89
+ document.body.style.overflow = this.originalOverflow || ""
61
90
  }
62
- }, 150)
91
+ this.hideTimeoutId = null
92
+ }, this.hideDelayValue)
93
+ } else {
94
+ // No content target, restore scroll immediately
95
+ document.body.style.overflow = this.originalOverflow || ""
63
96
  }
64
97
 
65
- // Remove event listeners
66
- document.removeEventListener("click", this.boundHandleClickOutside)
67
- document.removeEventListener("contextmenu", this.boundHandleClickOutside)
68
- document.removeEventListener("keydown", this.boundHandleKeydown)
69
-
70
98
  // Reset focus index
71
99
  this.focusedIndex = -1
72
100
 
73
101
  this.dispatch("closed")
74
102
  }
75
103
 
76
- close() {
77
- this.hide()
78
- }
79
-
80
- selectItem(event) {
81
- const item = event.currentTarget
82
- if (item.dataset.disabled !== undefined) return
83
-
84
- this.dispatch("select", { detail: { item } })
85
- this.hide()
86
- }
87
-
88
- handleClickOutside(event) {
89
- // Don't close if clicking inside the content
90
- if (this.hasContentTarget && this.contentTarget.contains(event.target)) {
104
+ handleContextMenu(event) {
105
+ // Don't close if right-clicking on the trigger element
106
+ // This allows show() to be called again to reposition the menu
107
+ if (this.hasTriggerTarget && this.triggerTarget.contains(event.target)) {
91
108
  return
92
109
  }
93
- this.hide()
94
- }
95
-
96
- handleKeydown(event) {
97
- switch (event.key) {
98
- case "Escape":
99
- this.hide()
100
- break
101
- case "ArrowDown":
102
- event.preventDefault()
103
- this.focusNextItem()
104
- break
105
- case "ArrowUp":
106
- event.preventDefault()
107
- this.focusPreviousItem()
108
- break
109
- case "Home":
110
- event.preventDefault()
111
- this.focusFirstItem()
112
- break
113
- case "End":
114
- event.preventDefault()
115
- this.focusLastItem()
116
- break
117
- case "Enter":
118
- case " ":
119
- event.preventDefault()
120
- this.selectFocusedItem()
121
- break
110
+ // Close if right-clicking outside the content
111
+ if (this.hasContentTarget && !this.contentTarget.contains(event.target)) {
112
+ this.hide()
122
113
  }
123
114
  }
124
115
 
125
- focusNextItem() {
126
- const items = this.enabledItems
127
- if (items.length === 0) return
128
-
129
- this.focusedIndex = (this.focusedIndex + 1) % items.length
130
- items[this.focusedIndex].focus()
131
- }
132
-
133
- focusPreviousItem() {
134
- const items = this.enabledItems
135
- if (items.length === 0) return
136
-
137
- this.focusedIndex = this.focusedIndex <= 0 ? items.length - 1 : this.focusedIndex - 1
138
- items[this.focusedIndex].focus()
139
- }
140
-
141
- focusFirstItem() {
142
- const items = this.enabledItems
143
- if (items.length === 0) return
144
-
145
- this.focusedIndex = 0
146
- items[0].focus()
147
- }
148
-
149
- focusLastItem() {
150
- const items = this.enabledItems
151
- if (items.length === 0) return
152
-
153
- this.focusedIndex = items.length - 1
154
- items[this.focusedIndex].focus()
155
- }
156
-
157
- selectFocusedItem() {
158
- const items = this.enabledItems
159
- if (this.focusedIndex >= 0 && this.focusedIndex < items.length) {
160
- items[this.focusedIndex].click()
116
+ shouldCloseOnClickOutside(event) {
117
+ // Don't close if clicking inside the content
118
+ if (this.hasContentTarget && this.contentTarget.contains(event.target)) {
119
+ return false
161
120
  }
162
- }
163
-
164
- get enabledItems() {
165
- return this.itemTargets.filter(item => item.dataset.disabled === undefined)
121
+ return true
166
122
  }
167
123
 
168
124
  positionContent() {
@@ -1,185 +1,23 @@
1
- import { Controller } from "@hotwired/stimulus"
1
+ import BaseMenuController from "./base_menu_controller"
2
2
 
3
3
  /**
4
4
  * Dropdown controller for dropdown menus
5
- * Handles opening, closing, keyboard navigation, and item selection
5
+ * Extends BaseMenuController with dropdown-specific positioning
6
6
  */
7
- export default class extends Controller {
8
- static targets = ["trigger", "content", "item"]
7
+ export default class extends BaseMenuController {
8
+ static targets = [...BaseMenuController.targets]
9
9
  static values = {
10
- open: { type: Boolean, default: false },
10
+ ...BaseMenuController.values,
11
11
  align: { type: String, default: "end" },
12
12
  side: { type: String, default: "bottom" }
13
13
  }
14
14
 
15
- connect() {
16
- this.focusedIndex = -1
17
- this.boundHandleClickOutside = this.handleClickOutside.bind(this)
18
- this.boundHandleKeydown = this.handleKeydown.bind(this)
19
-
20
- if (this.openValue) {
21
- this.show()
22
- }
23
- }
24
-
25
- disconnect() {
26
- this.hide()
27
- }
28
-
29
- toggle(event) {
30
- event?.preventDefault()
31
- if (this.openValue) {
32
- this.hide()
33
- } else {
34
- this.show()
35
- }
36
- }
37
-
38
- show() {
39
- if (this.openValue) return
40
-
41
- this.openValue = true
42
-
15
+ show(event) {
16
+ // Store side value for positioning before showing
43
17
  if (this.hasContentTarget) {
44
- this.contentTarget.hidden = false
45
- this.contentTarget.dataset.state = "open"
46
18
  this.contentTarget.dataset.side = this.sideValue
47
- this.positionContent()
48
- }
49
-
50
- if (this.hasTriggerTarget) {
51
- this.triggerTarget.setAttribute("aria-expanded", "true")
52
- }
53
-
54
- // Add event listeners
55
- document.addEventListener("click", this.boundHandleClickOutside)
56
- document.addEventListener("keydown", this.boundHandleKeydown)
57
-
58
- // Focus first item
59
- this.focusedIndex = -1
60
- this.focusNextItem()
61
-
62
- this.dispatch("opened")
63
- }
64
-
65
- hide() {
66
- if (!this.openValue) return
67
-
68
- this.openValue = false
69
-
70
- if (this.hasContentTarget) {
71
- this.contentTarget.dataset.state = "closed"
72
- // Hide after animation
73
- setTimeout(() => {
74
- if (!this.openValue) {
75
- this.contentTarget.hidden = true
76
- }
77
- }, 150)
78
- }
79
-
80
- if (this.hasTriggerTarget) {
81
- this.triggerTarget.setAttribute("aria-expanded", "false")
82
- }
83
-
84
- // Remove event listeners
85
- document.removeEventListener("click", this.boundHandleClickOutside)
86
- document.removeEventListener("keydown", this.boundHandleKeydown)
87
-
88
- // Reset focus index
89
- this.focusedIndex = -1
90
-
91
- this.dispatch("closed")
92
- }
93
-
94
- close() {
95
- this.hide()
96
- }
97
-
98
- selectItem(event) {
99
- const item = event.currentTarget
100
- if (item.dataset.disabled !== undefined) return
101
-
102
- this.dispatch("select", { detail: { item } })
103
- this.hide()
104
- }
105
-
106
- handleClickOutside(event) {
107
- if (!this.element.contains(event.target)) {
108
- this.hide()
109
19
  }
110
- }
111
-
112
- handleKeydown(event) {
113
- switch (event.key) {
114
- case "Escape":
115
- this.hide()
116
- this.triggerTarget?.focus()
117
- break
118
- case "ArrowDown":
119
- event.preventDefault()
120
- this.focusNextItem()
121
- break
122
- case "ArrowUp":
123
- event.preventDefault()
124
- this.focusPreviousItem()
125
- break
126
- case "Home":
127
- event.preventDefault()
128
- this.focusFirstItem()
129
- break
130
- case "End":
131
- event.preventDefault()
132
- this.focusLastItem()
133
- break
134
- case "Enter":
135
- case " ":
136
- event.preventDefault()
137
- this.selectFocusedItem()
138
- break
139
- }
140
- }
141
-
142
- focusNextItem() {
143
- const items = this.enabledItems
144
- if (items.length === 0) return
145
-
146
- this.focusedIndex = (this.focusedIndex + 1) % items.length
147
- items[this.focusedIndex].focus()
148
- }
149
-
150
- focusPreviousItem() {
151
- const items = this.enabledItems
152
- if (items.length === 0) return
153
-
154
- this.focusedIndex = this.focusedIndex <= 0 ? items.length - 1 : this.focusedIndex - 1
155
- items[this.focusedIndex].focus()
156
- }
157
-
158
- focusFirstItem() {
159
- const items = this.enabledItems
160
- if (items.length === 0) return
161
-
162
- this.focusedIndex = 0
163
- items[0].focus()
164
- }
165
-
166
- focusLastItem() {
167
- const items = this.enabledItems
168
- if (items.length === 0) return
169
-
170
- this.focusedIndex = items.length - 1
171
- items[this.focusedIndex].focus()
172
- }
173
-
174
- selectFocusedItem() {
175
- const items = this.enabledItems
176
- if (this.focusedIndex >= 0 && this.focusedIndex < items.length) {
177
- items[this.focusedIndex].click()
178
- }
179
- }
180
-
181
- get enabledItems() {
182
- return this.itemTargets.filter(item => item.dataset.disabled === undefined)
20
+ super.show(event)
183
21
  }
184
22
 
185
23
  positionContent() {
@@ -222,4 +60,45 @@ export default class extends Controller {
222
60
  break
223
61
  }
224
62
  }
63
+
64
+ toggleCheckbox(event) {
65
+ const item = event.currentTarget
66
+ if (item.dataset.disabled !== undefined) return
67
+
68
+ const isChecked = item.dataset.state === "checked"
69
+ item.dataset.state = isChecked ? "unchecked" : "checked"
70
+ item.setAttribute("aria-checked", (!isChecked).toString())
71
+
72
+ // Toggle the check icon visibility
73
+ const indicator = item.querySelector("span svg")
74
+ if (indicator) {
75
+ indicator.style.display = isChecked ? "none" : "block"
76
+ }
77
+
78
+ this.dispatch("check", { detail: { item, checked: !isChecked } })
79
+ }
80
+
81
+ selectRadio(event) {
82
+ const item = event.currentTarget
83
+ if (item.dataset.disabled !== undefined) return
84
+
85
+ const group = item.closest("[role='group']")
86
+ if (group) {
87
+ // Uncheck all radio items in the group
88
+ group.querySelectorAll("[role='menuitemradio']").forEach(radio => {
89
+ radio.dataset.state = "unchecked"
90
+ radio.setAttribute("aria-checked", "false")
91
+ const indicator = radio.querySelector("span svg")
92
+ if (indicator) indicator.style.display = "none"
93
+ })
94
+ }
95
+
96
+ // Check this item
97
+ item.dataset.state = "checked"
98
+ item.setAttribute("aria-checked", "true")
99
+ const indicator = item.querySelector("span svg")
100
+ if (indicator) indicator.style.display = "block"
101
+
102
+ this.dispatch("radioChange", { detail: { item, value: item.dataset.value } })
103
+ }
225
104
  }
@@ -1,8 +1,10 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { useClickOutside } from "stimulus-use"
2
3
 
3
4
  /**
4
5
  * Menubar controller
5
6
  * Handles menu opening/closing, keyboard navigation, hover behavior
7
+ * Uses stimulus-use for click outside detection
6
8
  */
7
9
  export default class extends Controller {
8
10
  static targets = ["menu", "trigger", "content", "item", "sub", "subTrigger", "subContent"]
@@ -13,14 +15,15 @@ export default class extends Controller {
13
15
  connect() {
14
16
  this.focusedIndex = -1
15
17
  this.isMenuOpen = false
16
- this.boundHandleClickOutside = this.handleClickOutside.bind(this)
17
18
  this.boundHandleKeydown = this.handleKeydown.bind(this)
18
19
  this.closeSubTimer = null
20
+
21
+ // Use stimulus-use for click outside detection
22
+ useClickOutside(this)
19
23
  }
20
24
 
21
25
  disconnect() {
22
26
  this.closeAll()
23
- document.removeEventListener("click", this.boundHandleClickOutside)
24
27
  document.removeEventListener("keydown", this.boundHandleKeydown)
25
28
  }
26
29
 
@@ -72,8 +75,7 @@ export default class extends Controller {
72
75
  this.isMenuOpen = true
73
76
  this.focusedIndex = -1
74
77
 
75
- // Add event listeners
76
- document.addEventListener("click", this.boundHandleClickOutside)
78
+ // Add keydown event listener (click outside is handled by stimulus-use)
77
79
  document.addEventListener("keydown", this.boundHandleKeydown)
78
80
 
79
81
  // Focus first item
@@ -100,7 +102,7 @@ export default class extends Controller {
100
102
  this.isMenuOpen = false
101
103
  this.focusedIndex = -1
102
104
 
103
- document.removeEventListener("click", this.boundHandleClickOutside)
105
+ // Remove keydown listener (click outside is handled by stimulus-use)
104
106
  document.removeEventListener("keydown", this.boundHandleKeydown)
105
107
  }
106
108
 
@@ -198,8 +200,9 @@ export default class extends Controller {
198
200
  })
199
201
  }
200
202
 
201
- handleClickOutside(event) {
202
- if (!this.element.contains(event.target)) {
203
+ // Called by stimulus-use when clicking outside the element
204
+ clickOutside(event) {
205
+ if (this.isMenuOpen) {
203
206
  this.closeAll()
204
207
  }
205
208
  }
@@ -1,8 +1,10 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { useClickOutside } from "stimulus-use"
2
3
 
3
4
  /**
4
5
  * Navigation Menu Controller
5
6
  * Handles navigation menu interactions with dropdown content areas
7
+ * Uses stimulus-use for click outside detection
6
8
  */
7
9
  export default class extends Controller {
8
10
  static targets = ["list", "item", "trigger", "content", "viewport"]
@@ -19,8 +21,10 @@ export default class extends Controller {
19
21
  this.closeTimer = null
20
22
  this.wasClickOpened = false
21
23
 
22
- this.boundHandleClickOutside = this.handleClickOutside.bind(this)
23
24
  this.boundHandleKeydown = this.handleKeydown.bind(this)
25
+
26
+ // Use stimulus-use for click outside detection
27
+ useClickOutside(this)
24
28
  }
25
29
 
26
30
  disconnect() {
@@ -125,8 +129,7 @@ export default class extends Controller {
125
129
 
126
130
  this.isOpen = true
127
131
 
128
- // Add event listeners
129
- document.addEventListener("click", this.boundHandleClickOutside)
132
+ // Add keydown event listener (click outside is handled by stimulus-use)
130
133
  document.addEventListener("keydown", this.boundHandleKeydown)
131
134
  }
132
135
 
@@ -183,12 +186,13 @@ export default class extends Controller {
183
186
  this.isOpen = false
184
187
  this.wasClickOpened = false
185
188
 
186
- document.removeEventListener("click", this.boundHandleClickOutside)
189
+ // Remove keydown listener (click outside is handled by stimulus-use)
187
190
  document.removeEventListener("keydown", this.boundHandleKeydown)
188
191
  }
189
192
 
190
- handleClickOutside(event) {
191
- if (!this.element.contains(event.target)) {
193
+ // Called by stimulus-use when clicking outside the element
194
+ clickOutside(event) {
195
+ if (this.isOpen) {
192
196
  this.closeAll()
193
197
  }
194
198
  }
@@ -1,7 +1,9 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { useClickOutside } from "stimulus-use"
2
3
 
3
4
  /**
4
5
  * Popover controller for rich content overlays
6
+ * Uses stimulus-use for click outside detection
5
7
  */
6
8
  export default class extends Controller {
7
9
  static targets = ["trigger", "content"]
@@ -13,7 +15,8 @@ export default class extends Controller {
13
15
  }
14
16
 
15
17
  connect() {
16
- this.boundHandleClickOutside = this.handleClickOutside.bind(this)
18
+ // Use stimulus-use for click outside detection
19
+ useClickOutside(this)
17
20
 
18
21
  if (this.openValue) {
19
22
  this.show()
@@ -45,8 +48,6 @@ export default class extends Controller {
45
48
  this.positionContent()
46
49
  }
47
50
 
48
- document.addEventListener("click", this.boundHandleClickOutside)
49
-
50
51
  if (this.modalValue) {
51
52
  document.body.style.pointerEvents = "none"
52
53
  this.contentTarget.style.pointerEvents = "auto"
@@ -69,8 +70,6 @@ export default class extends Controller {
69
70
  }, 150)
70
71
  }
71
72
 
72
- document.removeEventListener("click", this.boundHandleClickOutside)
73
-
74
73
  if (this.modalValue) {
75
74
  document.body.style.pointerEvents = ""
76
75
  }
@@ -82,8 +81,9 @@ export default class extends Controller {
82
81
  this.hide()
83
82
  }
84
83
 
85
- handleClickOutside(event) {
86
- if (!this.element.contains(event.target)) {
84
+ // Called by stimulus-use when clicking outside the element
85
+ clickOutside(event) {
86
+ if (this.openValue) {
87
87
  this.hide()
88
88
  }
89
89
  }
@@ -1,7 +1,9 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { useClickOutside } from "stimulus-use"
2
3
 
3
4
  /**
4
5
  * Select controller for custom select dropdowns
6
+ * Uses stimulus-use for click outside detection
5
7
  */
6
8
  export default class extends Controller {
7
9
  static targets = ["trigger", "content", "input", "item", "display", "checkIcon"]
@@ -12,7 +14,9 @@ export default class extends Controller {
12
14
  connect() {
13
15
  this.isOpen = false
14
16
  this.focusedIndex = -1
15
- this.boundHandleClickOutside = this.handleClickOutside.bind(this)
17
+
18
+ // Use stimulus-use for click outside detection
19
+ useClickOutside(this)
16
20
 
17
21
  // Set initial value display
18
22
  if (this.valueValue) {
@@ -53,8 +57,6 @@ export default class extends Controller {
53
57
  this.triggerTarget.setAttribute("aria-expanded", "true")
54
58
  }
55
59
 
56
- document.addEventListener("click", this.boundHandleClickOutside)
57
-
58
60
  // Focus current value or first item
59
61
  this.focusedIndex = -1
60
62
  const currentItem = this.itemTargets.find(item => item.dataset.value === this.valueValue)
@@ -86,12 +88,18 @@ export default class extends Controller {
86
88
  this.triggerTarget.setAttribute("aria-expanded", "false")
87
89
  }
88
90
 
89
- document.removeEventListener("click", this.boundHandleClickOutside)
90
91
  this.focusedIndex = -1
91
92
 
92
93
  this.dispatch("closed")
93
94
  }
94
95
 
96
+ // Called by stimulus-use when clicking outside the element
97
+ clickOutside(event) {
98
+ if (this.isOpen) {
99
+ this.close()
100
+ }
101
+ }
102
+
95
103
  select(event) {
96
104
  const item = event.currentTarget
97
105
  if (item.dataset.disabled !== undefined) return
@@ -132,12 +140,6 @@ export default class extends Controller {
132
140
  }
133
141
  }
134
142
 
135
- handleClickOutside(event) {
136
- if (!this.element.contains(event.target)) {
137
- this.close()
138
- }
139
- }
140
-
141
143
  handleKeydown(event) {
142
144
  if (!this.isOpen) {
143
145
  if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown") {