shadcn-rails 0.1.0 → 0.2.1

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 (169) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -2
  3. data/README.md +102 -1398
  4. data/__mocks__/@floating-ui/dom.js +67 -0
  5. data/app/assets/javascripts/shadcn/controllers/base_menu_controller.js +266 -0
  6. data/app/assets/javascripts/shadcn/controllers/combobox_controller.js +34 -8
  7. data/app/assets/javascripts/shadcn/controllers/command_controller.js +5 -1
  8. data/app/assets/javascripts/shadcn/controllers/context_menu_controller.js +64 -135
  9. data/app/assets/javascripts/shadcn/controllers/dropdown_controller.js +56 -186
  10. data/app/assets/javascripts/shadcn/controllers/hover_card_controller.js +29 -55
  11. data/app/assets/javascripts/shadcn/controllers/menubar_controller.js +10 -7
  12. data/app/assets/javascripts/shadcn/controllers/navigation_menu_controller.js +10 -6
  13. data/app/assets/javascripts/shadcn/controllers/popover_controller.js +35 -60
  14. data/app/assets/javascripts/shadcn/controllers/select_controller.js +37 -17
  15. data/app/assets/javascripts/shadcn/controllers/sidebar_controller.js +24 -14
  16. data/app/assets/javascripts/shadcn/controllers/tooltip_controller.js +28 -59
  17. data/app/assets/javascripts/shadcn/index.js +9 -1
  18. data/app/assets/javascripts/shadcn/utils/floating.js +179 -0
  19. data/app/assets/stylesheets/shadcn/base.css +32 -0
  20. data/app/assets/stylesheets/shadcn/components.css +12 -0
  21. data/app/components/shadcn/accordion_component.html.erb +8 -0
  22. data/app/components/shadcn/accordion_component.rb +6 -15
  23. data/app/components/shadcn/alert_component.html.erb +6 -0
  24. data/app/components/shadcn/alert_component.rb +0 -18
  25. data/app/components/shadcn/alert_dialog_component.html.erb +12 -0
  26. data/app/components/shadcn/alert_dialog_component.rb +7 -27
  27. data/app/components/shadcn/aspect_ratio_component.html.erb +7 -0
  28. data/app/components/shadcn/aspect_ratio_component.rb +4 -19
  29. data/app/components/shadcn/avatar_component.html.erb +20 -0
  30. data/app/components/shadcn/avatar_component.rb +8 -36
  31. data/app/components/shadcn/badge_component.html.erb +1 -0
  32. data/app/components/shadcn/badge_component.rb +0 -11
  33. data/app/components/shadcn/base_component.rb +15 -2
  34. data/app/components/shadcn/breadcrumb_component.html.erb +5 -0
  35. data/app/components/shadcn/breadcrumb_component.rb +6 -16
  36. data/app/components/shadcn/button_component.html.erb +18 -0
  37. data/app/components/shadcn/button_component.rb +1 -41
  38. data/app/components/shadcn/card_component.html.erb +8 -0
  39. data/app/components/shadcn/card_component.rb +2 -6
  40. data/app/components/shadcn/checkbox_component.html.erb +32 -0
  41. data/app/components/shadcn/checkbox_component.rb +4 -43
  42. data/app/components/shadcn/collapsible_component.html.erb +8 -0
  43. data/app/components/shadcn/collapsible_component.rb +6 -15
  44. data/app/components/shadcn/command_list_component.rb +29 -14
  45. data/app/components/shadcn/context_menu_checkbox_item_component.rb +76 -0
  46. data/app/components/shadcn/context_menu_component.html.erb +11 -0
  47. data/app/components/shadcn/context_menu_component.rb +6 -26
  48. data/app/components/shadcn/context_menu_content_component.rb +37 -14
  49. data/app/components/shadcn/context_menu_item_component.rb +3 -2
  50. data/app/components/shadcn/context_menu_radio_group_component.rb +42 -0
  51. data/app/components/shadcn/context_menu_radio_item_component.rb +76 -0
  52. data/app/components/shadcn/dialog_component.html.erb +14 -0
  53. data/app/components/shadcn/dialog_component.rb +8 -29
  54. data/app/components/shadcn/drawer_component.html.erb +12 -0
  55. data/app/components/shadcn/drawer_component.rb +7 -27
  56. data/app/components/shadcn/dropdown_menu_checkbox_item_component.rb +76 -0
  57. data/app/components/shadcn/dropdown_menu_component.html.erb +14 -0
  58. data/app/components/shadcn/dropdown_menu_component.rb +9 -29
  59. data/app/components/shadcn/dropdown_menu_content_component.rb +45 -16
  60. data/app/components/shadcn/dropdown_menu_radio_group_component.rb +42 -0
  61. data/app/components/shadcn/dropdown_menu_radio_item_component.rb +76 -0
  62. data/app/components/shadcn/field_component.rb +7 -8
  63. data/app/components/shadcn/hover_card_component.html.erb +12 -0
  64. data/app/components/shadcn/hover_card_component.rb +7 -26
  65. data/app/components/shadcn/input_component.html.erb +18 -0
  66. data/app/components/shadcn/input_component.rb +2 -27
  67. data/app/components/shadcn/input_otp_component.rb +3 -3
  68. data/app/components/shadcn/kbd_component.html.erb +1 -0
  69. data/app/components/shadcn/kbd_component.rb +3 -10
  70. data/app/components/shadcn/label_component.html.erb +3 -0
  71. data/app/components/shadcn/label_component.rb +2 -18
  72. data/app/components/shadcn/menubar_component.html.erb +6 -0
  73. data/app/components/shadcn/menubar_component.rb +4 -15
  74. data/app/components/shadcn/menubar_content_component.rb +45 -20
  75. data/app/components/shadcn/menubar_sub_content_component.rb +21 -8
  76. data/app/components/shadcn/native_select_component.html.erb +22 -0
  77. data/app/components/shadcn/native_select_component.rb +9 -39
  78. data/app/components/shadcn/navigation_menu_component.html.erb +6 -0
  79. data/app/components/shadcn/navigation_menu_component.rb +4 -15
  80. data/app/components/shadcn/pagination_component.html.erb +5 -0
  81. data/app/components/shadcn/pagination_component.rb +11 -15
  82. data/app/components/shadcn/popover_component.html.erb +15 -0
  83. data/app/components/shadcn/popover_component.rb +10 -30
  84. data/app/components/shadcn/progress_component.html.erb +13 -0
  85. data/app/components/shadcn/progress_component.rb +6 -26
  86. data/app/components/shadcn/radio_group_component.html.erb +8 -0
  87. data/app/components/shadcn/radio_group_component.rb +12 -26
  88. data/app/components/shadcn/radio_group_item_component.rb +32 -6
  89. data/app/components/shadcn/resizable_panel_group_component.rb +27 -16
  90. data/app/components/shadcn/scroll_area_component.html.erb +7 -0
  91. data/app/components/shadcn/scroll_area_component.rb +4 -16
  92. data/app/components/shadcn/select_component.html.erb +46 -0
  93. data/app/components/shadcn/select_component.rb +29 -86
  94. data/app/components/shadcn/separator_component.html.erb +5 -0
  95. data/app/components/shadcn/separator_component.rb +6 -14
  96. data/app/components/shadcn/sheet_component.html.erb +12 -0
  97. data/app/components/shadcn/sheet_component.rb +7 -27
  98. data/app/components/shadcn/sidebar_component.rb +2 -2
  99. data/app/components/shadcn/skeleton_component.html.erb +1 -0
  100. data/app/components/shadcn/skeleton_component.rb +4 -2
  101. data/app/components/shadcn/slider_component.html.erb +12 -0
  102. data/app/components/shadcn/slider_component.rb +2 -21
  103. data/app/components/shadcn/spinner_component.html.erb +18 -0
  104. data/app/components/shadcn/spinner_component.rb +2 -30
  105. data/app/components/shadcn/switch_component.html.erb +72 -0
  106. data/app/components/shadcn/switch_component.rb +4 -82
  107. data/app/components/shadcn/table_component.html.erb +9 -0
  108. data/app/components/shadcn/table_component.rb +2 -10
  109. data/app/components/shadcn/tabs_component.html.erb +8 -0
  110. data/app/components/shadcn/tabs_component.rb +4 -17
  111. data/app/components/shadcn/textarea_component.html.erb +13 -0
  112. data/app/components/shadcn/textarea_component.rb +6 -22
  113. data/app/components/shadcn/toast_component.html.erb +36 -0
  114. data/app/components/shadcn/toast_component.rb +6 -54
  115. data/app/components/shadcn/toggle_component.html.erb +12 -0
  116. data/app/components/shadcn/toggle_component.rb +6 -21
  117. data/app/components/shadcn/toggle_group_component.html.erb +14 -0
  118. data/app/components/shadcn/toggle_group_component.rb +6 -29
  119. data/app/components/shadcn/tooltip_component.html.erb +20 -0
  120. data/app/components/shadcn/tooltip_component.rb +13 -38
  121. data/lib/generators/shadcn/add/USAGE +24 -0
  122. data/lib/generators/shadcn/add/add_generator.rb +279 -0
  123. data/lib/generators/shadcn/install/USAGE +22 -0
  124. data/lib/generators/shadcn/install/install_generator.rb +8 -3
  125. data/lib/generators/shadcn/install/templates/initializer.rb.tt +7 -27
  126. data/lib/generators/shadcn/install/templates/shadcn.yml.tt +15 -31
  127. data/lib/shadcn/rails/version.rb +1 -1
  128. metadata +54 -42
  129. data/.dockerignore +0 -40
  130. data/CLAUDE.md +0 -463
  131. data/PROGRESS.md +0 -485
  132. data/Rakefile +0 -29
  133. data/__tests__/controllers/__snapshots__/calendar_controller.test.js.snap +0 -13
  134. data/__tests__/controllers/__snapshots__/popover_controller.test.js.snap +0 -46
  135. data/__tests__/controllers/__snapshots__/sheet_controller.test.js.snap +0 -111
  136. data/__tests__/controllers/__snapshots__/tabs_controller.test.js.snap +0 -27
  137. data/__tests__/controllers/accordion_controller.test.js +0 -904
  138. data/__tests__/controllers/calendar_controller.test.js +0 -1370
  139. data/__tests__/controllers/carousel_controller.test.js +0 -912
  140. data/__tests__/controllers/checkbox_controller.test.js +0 -454
  141. data/__tests__/controllers/collapsible_controller.test.js +0 -407
  142. data/__tests__/controllers/combobox_controller.test.js +0 -966
  143. data/__tests__/controllers/context_menu_controller.test.js +0 -627
  144. data/__tests__/controllers/date_picker_controller.test.js +0 -636
  145. data/__tests__/controllers/dialog_controller.test.js +0 -878
  146. data/__tests__/controllers/drawer_controller.test.js +0 -995
  147. data/__tests__/controllers/menubar_controller.test.js +0 -736
  148. data/__tests__/controllers/navigation_menu_controller.test.js +0 -598
  149. data/__tests__/controllers/popover_controller.test.js +0 -1007
  150. data/__tests__/controllers/radio_group_controller.test.js +0 -640
  151. data/__tests__/controllers/resizable_controller.test.js +0 -680
  152. data/__tests__/controllers/select_controller.test.js +0 -674
  153. data/__tests__/controllers/sheet_controller.test.js +0 -986
  154. data/__tests__/controllers/slider_controller.test.js +0 -1036
  155. data/__tests__/controllers/switch_controller.test.js +0 -424
  156. data/__tests__/controllers/tabs_controller.test.js +0 -907
  157. data/__tests__/controllers/toggle_group_controller.test.js +0 -839
  158. data/__tests__/controllers/tooltip_controller.test.js +0 -808
  159. data/__tests__/helpers/stimulus-test-helper.js +0 -203
  160. data/babel.config.cjs +0 -5
  161. data/bin/console +0 -11
  162. data/bin/setup +0 -8
  163. data/jest.config.js +0 -19
  164. data/jest.setup.js +0 -8
  165. data/lib/generators/shadcn/component/component_generator.rb +0 -188
  166. data/lib/generators/shadcn/theme/theme_generator.rb +0 -128
  167. data/package-lock.json +0 -7415
  168. data/package.json +0 -68
  169. data/rollup.config.js +0 -29
@@ -1,225 +1,95 @@
1
- import { Controller } from "@hotwired/stimulus"
1
+ import BaseMenuController from "./base_menu_controller"
2
+ import { positionFloating } from "../utils/floating"
2
3
 
3
4
  /**
4
5
  * Dropdown controller for dropdown menus
5
- * Handles opening, closing, keyboard navigation, and item selection
6
+ * Extends BaseMenuController with Floating UI positioning
6
7
  */
7
- export default class extends Controller {
8
- static targets = ["trigger", "content", "item"]
8
+ export default class extends BaseMenuController {
9
+ static targets = [...BaseMenuController.targets]
9
10
  static values = {
10
- open: { type: Boolean, default: false },
11
+ ...BaseMenuController.values,
11
12
  align: { type: String, default: "end" },
12
13
  side: { type: String, default: "bottom" }
13
14
  }
14
15
 
15
16
  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
- }
17
+ this.cleanupFloating = null
18
+ super.connect()
23
19
  }
24
20
 
25
21
  disconnect() {
26
- this.hide()
22
+ this.cleanupPositioning()
23
+ super.disconnect()
27
24
  }
28
25
 
29
- toggle(event) {
30
- event?.preventDefault()
31
- if (this.openValue) {
32
- this.hide()
33
- } else {
34
- this.show()
26
+ cleanupPositioning() {
27
+ if (this.cleanupFloating) {
28
+ this.cleanupFloating()
29
+ this.cleanupFloating = null
35
30
  }
36
31
  }
37
32
 
38
- show() {
39
- if (this.openValue) return
40
-
41
- this.openValue = true
42
-
43
- if (this.hasContentTarget) {
44
- this.contentTarget.hidden = false
45
- this.contentTarget.dataset.state = "open"
46
- 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")
33
+ get placement() {
34
+ // Convert side/align to Floating UI placement
35
+ const align = this.alignValue === "center" ? "" : `-${this.alignValue}`
36
+ return `${this.sideValue}${align}`
63
37
  }
64
38
 
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
39
+ positionContent() {
40
+ if (!this.hasContentTarget || !this.hasTriggerTarget) return
90
41
 
91
- this.dispatch("closed")
42
+ // Use Floating UI for smart positioning
43
+ this.cleanupFloating = positionFloating(this.triggerTarget, this.contentTarget, {
44
+ placement: this.placement,
45
+ offset: 4,
46
+ sameWidth: false
47
+ })
92
48
  }
93
49
 
94
- close() {
95
- this.hide()
50
+ hideMenu() {
51
+ this.cleanupPositioning()
52
+ super.hideMenu()
96
53
  }
97
54
 
98
- selectItem(event) {
55
+ toggleCheckbox(event) {
99
56
  const item = event.currentTarget
100
57
  if (item.dataset.disabled !== undefined) return
101
58
 
102
- this.dispatch("select", { detail: { item } })
103
- this.hide()
104
- }
59
+ const isChecked = item.dataset.state === "checked"
60
+ item.dataset.state = isChecked ? "unchecked" : "checked"
61
+ item.setAttribute("aria-checked", (!isChecked).toString())
105
62
 
106
- handleClickOutside(event) {
107
- if (!this.element.contains(event.target)) {
108
- this.hide()
63
+ // Toggle the check icon visibility
64
+ const indicator = item.querySelector("span svg")
65
+ if (indicator) {
66
+ indicator.style.display = isChecked ? "none" : "block"
109
67
  }
110
- }
111
68
 
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
- }
69
+ this.dispatch("check", { detail: { item, checked: !isChecked } })
140
70
  }
141
71
 
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
- }
72
+ selectRadio(event) {
73
+ const item = event.currentTarget
74
+ if (item.dataset.disabled !== undefined) return
173
75
 
174
- selectFocusedItem() {
175
- const items = this.enabledItems
176
- if (this.focusedIndex >= 0 && this.focusedIndex < items.length) {
177
- items[this.focusedIndex].click()
76
+ const group = item.closest("[role='group']")
77
+ if (group) {
78
+ // Uncheck all radio items in the group
79
+ group.querySelectorAll("[role='menuitemradio']").forEach(radio => {
80
+ radio.dataset.state = "unchecked"
81
+ radio.setAttribute("aria-checked", "false")
82
+ const indicator = radio.querySelector("span svg")
83
+ if (indicator) indicator.style.display = "none"
84
+ })
178
85
  }
179
- }
180
-
181
- get enabledItems() {
182
- return this.itemTargets.filter(item => item.dataset.disabled === undefined)
183
- }
184
86
 
185
- positionContent() {
186
- if (!this.hasContentTarget || !this.hasTriggerTarget) return
87
+ // Check this item
88
+ item.dataset.state = "checked"
89
+ item.setAttribute("aria-checked", "true")
90
+ const indicator = item.querySelector("span svg")
91
+ if (indicator) indicator.style.display = "block"
187
92
 
188
- const trigger = this.triggerTarget.getBoundingClientRect()
189
- const content = this.contentTarget
190
-
191
- // Position based on side and align
192
- content.style.position = "absolute"
193
- content.style.minWidth = `${trigger.width}px`
194
-
195
- switch (this.sideValue) {
196
- case "top":
197
- content.style.bottom = "100%"
198
- content.style.top = "auto"
199
- content.style.marginBottom = "4px"
200
- break
201
- case "bottom":
202
- default:
203
- content.style.top = "100%"
204
- content.style.bottom = "auto"
205
- content.style.marginTop = "4px"
206
- break
207
- }
208
-
209
- switch (this.alignValue) {
210
- case "start":
211
- content.style.left = "0"
212
- content.style.right = "auto"
213
- break
214
- case "center":
215
- content.style.left = "50%"
216
- content.style.transform = "translateX(-50%)"
217
- break
218
- case "end":
219
- default:
220
- content.style.right = "0"
221
- content.style.left = "auto"
222
- break
223
- }
93
+ this.dispatch("radioChange", { detail: { item, value: item.dataset.value } })
224
94
  }
225
95
  }
@@ -1,20 +1,25 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { positionFloating } from "../utils/floating"
2
3
 
3
4
  /**
4
5
  * Hover Card Controller
5
6
  * Handles showing/hiding content on hover with delays
7
+ * Uses Floating UI for smart positioning
6
8
  */
7
9
  export default class extends Controller {
8
10
  static targets = ["trigger", "content"]
9
11
  static values = {
10
12
  openDelay: { type: Number, default: 700 },
11
- closeDelay: { type: Number, default: 300 }
13
+ closeDelay: { type: Number, default: 300 },
14
+ side: { type: String, default: "bottom" },
15
+ align: { type: String, default: "center" }
12
16
  }
13
17
 
14
18
  connect() {
15
19
  this.openTimeout = null
16
20
  this.closeTimeout = null
17
21
  this.isOpen = false
22
+ this.cleanupFloating = null
18
23
 
19
24
  this.triggerTarget.addEventListener("mouseenter", this.scheduleOpen.bind(this))
20
25
  this.triggerTarget.addEventListener("mouseleave", this.scheduleClose.bind(this))
@@ -27,6 +32,20 @@ export default class extends Controller {
27
32
 
28
33
  disconnect() {
29
34
  this.clearTimeouts()
35
+ this.cleanupPositioning()
36
+ }
37
+
38
+ cleanupPositioning() {
39
+ if (this.cleanupFloating) {
40
+ this.cleanupFloating()
41
+ this.cleanupFloating = null
42
+ }
43
+ }
44
+
45
+ get placement() {
46
+ // Convert side/align to Floating UI placement
47
+ const align = this.alignValue === "center" ? "" : `-${this.alignValue}`
48
+ return `${this.sideValue}${align}`
30
49
  }
31
50
 
32
51
  scheduleOpen() {
@@ -67,7 +86,12 @@ export default class extends Controller {
67
86
  this.isOpen = true
68
87
  this.contentTarget.style.display = "block"
69
88
  this.contentTarget.setAttribute("data-state", "open")
70
- this.positionContent()
89
+
90
+ // Use Floating UI for smart positioning
91
+ this.cleanupFloating = positionFloating(this.triggerTarget, this.contentTarget, {
92
+ placement: this.placement,
93
+ offset: 8
94
+ })
71
95
 
72
96
  this.dispatch("open")
73
97
  }
@@ -78,6 +102,9 @@ export default class extends Controller {
78
102
  this.isOpen = false
79
103
  this.contentTarget.setAttribute("data-state", "closed")
80
104
 
105
+ // Cleanup Floating UI
106
+ this.cleanupPositioning()
107
+
81
108
  // Wait for animation to complete
82
109
  setTimeout(() => {
83
110
  if (!this.isOpen) {
@@ -87,57 +114,4 @@ export default class extends Controller {
87
114
 
88
115
  this.dispatch("close")
89
116
  }
90
-
91
- positionContent() {
92
- const trigger = this.triggerTarget.getBoundingClientRect()
93
- const content = this.contentTarget
94
- const side = content.dataset.side || "bottom"
95
- const align = content.dataset.align || "center"
96
-
97
- // Reset position
98
- content.style.top = ""
99
- content.style.left = ""
100
- content.style.right = ""
101
- content.style.bottom = ""
102
-
103
- const gap = 8 // Gap between trigger and content
104
-
105
- switch (side) {
106
- case "top":
107
- content.style.bottom = "100%"
108
- content.style.marginBottom = `${gap}px`
109
- break
110
- case "bottom":
111
- content.style.top = "100%"
112
- content.style.marginTop = `${gap}px`
113
- break
114
- case "left":
115
- content.style.right = "100%"
116
- content.style.marginRight = `${gap}px`
117
- content.style.top = "0"
118
- break
119
- case "right":
120
- content.style.left = "100%"
121
- content.style.marginLeft = `${gap}px`
122
- content.style.top = "0"
123
- break
124
- }
125
-
126
- // Handle alignment for top/bottom
127
- if (side === "top" || side === "bottom") {
128
- switch (align) {
129
- case "start":
130
- content.style.left = "0"
131
- break
132
- case "end":
133
- content.style.right = "0"
134
- break
135
- case "center":
136
- default:
137
- content.style.left = "50%"
138
- content.style.transform = "translateX(-50%)"
139
- break
140
- }
141
- }
142
- }
143
117
  }
@@ -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,10 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { useClickOutside } from "stimulus-use"
3
+ import { positionFloating } from "../utils/floating"
2
4
 
3
5
  /**
4
6
  * Popover controller for rich content overlays
7
+ * Uses Floating UI for smart positioning and stimulus-use for click outside detection
5
8
  */
6
9
  export default class extends Controller {
7
10
  static targets = ["trigger", "content"]
@@ -13,7 +16,10 @@ export default class extends Controller {
13
16
  }
14
17
 
15
18
  connect() {
16
- this.boundHandleClickOutside = this.handleClickOutside.bind(this)
19
+ this.cleanupFloating = null
20
+
21
+ // Use stimulus-use for click outside detection
22
+ useClickOutside(this)
17
23
 
18
24
  if (this.openValue) {
19
25
  this.show()
@@ -22,6 +28,20 @@ export default class extends Controller {
22
28
 
23
29
  disconnect() {
24
30
  this.hide()
31
+ this.cleanupPositioning()
32
+ }
33
+
34
+ cleanupPositioning() {
35
+ if (this.cleanupFloating) {
36
+ this.cleanupFloating()
37
+ this.cleanupFloating = null
38
+ }
39
+ }
40
+
41
+ get placement() {
42
+ // Convert side/align to Floating UI placement
43
+ const align = this.alignValue === "center" ? "" : `-${this.alignValue}`
44
+ return `${this.sideValue}${align}`
25
45
  }
26
46
 
27
47
  toggle(event) {
@@ -41,11 +61,15 @@ export default class extends Controller {
41
61
  if (this.hasContentTarget) {
42
62
  this.contentTarget.hidden = false
43
63
  this.contentTarget.dataset.state = "open"
44
- this.contentTarget.dataset.side = this.sideValue
45
- this.positionContent()
46
- }
47
64
 
48
- document.addEventListener("click", this.boundHandleClickOutside)
65
+ // Use Floating UI for smart positioning
66
+ if (this.hasTriggerTarget) {
67
+ this.cleanupFloating = positionFloating(this.triggerTarget, this.contentTarget, {
68
+ placement: this.placement,
69
+ offset: 8
70
+ })
71
+ }
72
+ }
49
73
 
50
74
  if (this.modalValue) {
51
75
  document.body.style.pointerEvents = "none"
@@ -60,6 +84,9 @@ export default class extends Controller {
60
84
 
61
85
  this.openValue = false
62
86
 
87
+ // Cleanup Floating UI auto-update
88
+ this.cleanupPositioning()
89
+
63
90
  if (this.hasContentTarget) {
64
91
  this.contentTarget.dataset.state = "closed"
65
92
  setTimeout(() => {
@@ -69,8 +96,6 @@ export default class extends Controller {
69
96
  }, 150)
70
97
  }
71
98
 
72
- document.removeEventListener("click", this.boundHandleClickOutside)
73
-
74
99
  if (this.modalValue) {
75
100
  document.body.style.pointerEvents = ""
76
101
  }
@@ -82,60 +107,10 @@ export default class extends Controller {
82
107
  this.hide()
83
108
  }
84
109
 
85
- handleClickOutside(event) {
86
- if (!this.element.contains(event.target)) {
110
+ // Called by stimulus-use when clicking outside the element
111
+ clickOutside(event) {
112
+ if (this.openValue) {
87
113
  this.hide()
88
114
  }
89
115
  }
90
-
91
- positionContent() {
92
- if (!this.hasContentTarget || !this.hasTriggerTarget) return
93
-
94
- const trigger = this.triggerTarget.getBoundingClientRect()
95
- const content = this.contentTarget
96
-
97
- content.style.position = "absolute"
98
-
99
- const gap = 8
100
-
101
- switch (this.sideValue) {
102
- case "top":
103
- content.style.bottom = "100%"
104
- content.style.top = "auto"
105
- content.style.marginBottom = `${gap}px`
106
- break
107
- case "bottom":
108
- content.style.top = "100%"
109
- content.style.bottom = "auto"
110
- content.style.marginTop = `${gap}px`
111
- break
112
- case "left":
113
- content.style.right = "100%"
114
- content.style.left = "auto"
115
- content.style.marginRight = `${gap}px`
116
- break
117
- case "right":
118
- content.style.left = "100%"
119
- content.style.right = "auto"
120
- content.style.marginLeft = `${gap}px`
121
- break
122
- }
123
-
124
- switch (this.alignValue) {
125
- case "start":
126
- content.style.left = "0"
127
- content.style.right = "auto"
128
- break
129
- case "center":
130
- if (this.sideValue === "top" || this.sideValue === "bottom") {
131
- content.style.left = "50%"
132
- content.style.transform = "translateX(-50%)"
133
- }
134
- break
135
- case "end":
136
- content.style.right = "0"
137
- content.style.left = "auto"
138
- break
139
- }
140
- }
141
116
  }