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
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Mock for @floating-ui/dom
3
+ * Used in tests since JSDOM doesn't properly support the DOM APIs Floating UI needs
4
+ */
5
+
6
+ // Store references for size middleware to apply
7
+ let _computeRef = null
8
+ let _computeFloating = null
9
+
10
+ export const computePosition = async (reference, floating, options = {}) => {
11
+ _computeRef = reference
12
+ _computeFloating = floating
13
+
14
+ // Call size middleware apply if present
15
+ if (options.middleware) {
16
+ for (const mw of options.middleware) {
17
+ if (mw.name === 'size' && mw.applyFn) {
18
+ mw.applyFn({
19
+ availableWidth: 400,
20
+ availableHeight: 300,
21
+ elements: { floating },
22
+ rects: {
23
+ reference: {
24
+ width: reference?.getBoundingClientRect?.()?.width || 100,
25
+ height: reference?.getBoundingClientRect?.()?.height || 40
26
+ }
27
+ }
28
+ })
29
+ }
30
+ }
31
+ }
32
+
33
+ return {
34
+ x: 100,
35
+ y: 140,
36
+ placement: options.placement || 'bottom-start',
37
+ middlewareData: {}
38
+ }
39
+ }
40
+
41
+ export const autoUpdate = (reference, floating, update) => {
42
+ // Call update once immediately
43
+ update()
44
+ // Return cleanup function
45
+ return () => {}
46
+ }
47
+
48
+ export const flip = (options = {}) => ({
49
+ name: 'flip',
50
+ options
51
+ })
52
+
53
+ export const shift = (options = {}) => ({
54
+ name: 'shift',
55
+ options
56
+ })
57
+
58
+ export const offset = (value = 0) => ({
59
+ name: 'offset',
60
+ options: { mainAxis: value }
61
+ })
62
+
63
+ export const size = (options = {}) => ({
64
+ name: 'size',
65
+ options,
66
+ applyFn: options.apply
67
+ })
@@ -0,0 +1,266 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { useClickOutside } from "stimulus-use"
3
+
4
+ /**
5
+ * Base Menu Controller
6
+ *
7
+ * A base controller for menu-like components (dropdown, context menu, select, etc.)
8
+ * that provides common functionality for:
9
+ * - Opening/closing menus
10
+ * - Keyboard navigation (arrow keys, home, end, enter, space, escape)
11
+ * - Focus management
12
+ * - Click outside to close (using stimulus-use)
13
+ * - Item selection
14
+ *
15
+ * Subclasses can override specific methods to customize behavior:
16
+ * - positionContent() - Custom positioning logic
17
+ * - showMenu() - Additional show behavior
18
+ * - hideMenu() - Additional hide behavior
19
+ * - shouldCloseOnClickOutside(event) - Custom click outside logic
20
+ */
21
+ export default class extends Controller {
22
+ static targets = ["trigger", "content", "item"]
23
+ static values = {
24
+ open: { type: Boolean, default: false },
25
+ hideDelay: { type: Number, default: 150 }
26
+ }
27
+
28
+ // Lifecycle hooks
29
+ connect() {
30
+ this.focusedIndex = -1
31
+ this.hideTimeoutId = null
32
+ this.boundHandleKeydown = this.handleKeydown.bind(this)
33
+
34
+ // Use stimulus-use for click outside detection
35
+ useClickOutside(this)
36
+
37
+ if (this.openValue) {
38
+ this.show()
39
+ }
40
+ }
41
+
42
+ disconnect() {
43
+ this.hide()
44
+ }
45
+
46
+ // Public API
47
+ toggle(event) {
48
+ event?.preventDefault()
49
+ if (this.openValue) {
50
+ this.hide()
51
+ } else {
52
+ this.show()
53
+ }
54
+ }
55
+
56
+ show(event) {
57
+ if (this.openValue) return
58
+
59
+ // Cancel any pending hide timeout
60
+ this.cancelHideTimeout()
61
+
62
+ this.openValue = true
63
+
64
+ if (this.hasContentTarget) {
65
+ this.contentTarget.hidden = false
66
+ this.contentTarget.dataset.state = "open"
67
+ this.positionContent(event)
68
+ }
69
+
70
+ if (this.hasTriggerTarget) {
71
+ this.triggerTarget.setAttribute("aria-expanded", "true")
72
+ }
73
+
74
+ // Add event listeners
75
+ this.addEventListeners()
76
+
77
+ // Allow subclasses to add custom show behavior
78
+ this.showMenu(event)
79
+
80
+ // Focus first item
81
+ this.focusedIndex = -1
82
+ this.focusNextItem()
83
+
84
+ this.dispatch("opened")
85
+ }
86
+
87
+ hide() {
88
+ if (!this.openValue) return
89
+
90
+ this.openValue = false
91
+
92
+ // Remove event listeners immediately to prevent double-triggering
93
+ this.removeEventListeners()
94
+
95
+ if (this.hasContentTarget) {
96
+ this.contentTarget.dataset.state = "closed"
97
+ // Hide after animation completes
98
+ this.hideTimeoutId = setTimeout(() => {
99
+ if (!this.openValue && this.hasContentTarget) {
100
+ this.contentTarget.hidden = true
101
+ }
102
+ this.hideTimeoutId = null
103
+ // Allow subclasses to add custom hide behavior
104
+ this.hideMenu()
105
+ }, this.hideDelayValue)
106
+ } else {
107
+ this.hideMenu()
108
+ }
109
+
110
+ if (this.hasTriggerTarget) {
111
+ this.triggerTarget.setAttribute("aria-expanded", "false")
112
+ }
113
+
114
+ // Reset focus index
115
+ this.focusedIndex = -1
116
+
117
+ this.dispatch("closed")
118
+ }
119
+
120
+ close() {
121
+ this.hide()
122
+ }
123
+
124
+ selectItem(event) {
125
+ const item = event.currentTarget
126
+ if (item.dataset.disabled !== undefined) return
127
+
128
+ this.dispatch("select", { detail: { item } })
129
+ this.hide()
130
+ }
131
+
132
+ // Event handling - clickOutside is called by stimulus-use
133
+ clickOutside(event) {
134
+ // Only close if menu is open and shouldCloseOnClickOutside returns true
135
+ if (this.openValue && this.shouldCloseOnClickOutside(event)) {
136
+ this.hide()
137
+ }
138
+ }
139
+
140
+ handleKeydown(event) {
141
+ switch (event.key) {
142
+ case "Escape":
143
+ this.hide()
144
+ this.triggerTarget?.focus()
145
+ break
146
+ case "ArrowDown":
147
+ event.preventDefault()
148
+ this.focusNextItem()
149
+ break
150
+ case "ArrowUp":
151
+ event.preventDefault()
152
+ this.focusPreviousItem()
153
+ break
154
+ case "Home":
155
+ event.preventDefault()
156
+ this.focusFirstItem()
157
+ break
158
+ case "End":
159
+ event.preventDefault()
160
+ this.focusLastItem()
161
+ break
162
+ case "Enter":
163
+ case " ":
164
+ event.preventDefault()
165
+ this.selectFocusedItem()
166
+ break
167
+ }
168
+ }
169
+
170
+ // Focus management
171
+ focusNextItem() {
172
+ const items = this.enabledItems
173
+ if (items.length === 0) return
174
+
175
+ this.focusedIndex = (this.focusedIndex + 1) % items.length
176
+ items[this.focusedIndex].focus()
177
+ }
178
+
179
+ focusPreviousItem() {
180
+ const items = this.enabledItems
181
+ if (items.length === 0) return
182
+
183
+ this.focusedIndex = this.focusedIndex <= 0 ? items.length - 1 : this.focusedIndex - 1
184
+ items[this.focusedIndex].focus()
185
+ }
186
+
187
+ focusFirstItem() {
188
+ const items = this.enabledItems
189
+ if (items.length === 0) return
190
+
191
+ this.focusedIndex = 0
192
+ items[0].focus()
193
+ }
194
+
195
+ focusLastItem() {
196
+ const items = this.enabledItems
197
+ if (items.length === 0) return
198
+
199
+ this.focusedIndex = items.length - 1
200
+ items[this.focusedIndex].focus()
201
+ }
202
+
203
+ selectFocusedItem() {
204
+ const items = this.enabledItems
205
+ if (this.focusedIndex >= 0 && this.focusedIndex < items.length) {
206
+ items[this.focusedIndex].click()
207
+ }
208
+ }
209
+
210
+ get enabledItems() {
211
+ return this.itemTargets.filter(item => item.dataset.disabled === undefined)
212
+ }
213
+
214
+ // Protected methods that subclasses can override
215
+
216
+ /**
217
+ * Position the content element. Override in subclasses for custom positioning.
218
+ * @param {Event} event - The event that triggered the show (optional)
219
+ */
220
+ positionContent(event) {
221
+ // Default: no positioning (subclasses should override)
222
+ }
223
+
224
+ /**
225
+ * Called after showing the menu. Override in subclasses for additional behavior.
226
+ * @param {Event} event - The event that triggered the show (optional)
227
+ */
228
+ showMenu(event) {
229
+ // Default: no-op (subclasses can override)
230
+ }
231
+
232
+ /**
233
+ * Called after hiding the menu. Override in subclasses for additional behavior.
234
+ */
235
+ hideMenu() {
236
+ // Default: no-op (subclasses can override)
237
+ }
238
+
239
+ /**
240
+ * Determine if the menu should close on click outside.
241
+ * Override in subclasses for custom behavior (e.g., context menu).
242
+ * @param {Event} event - The click event
243
+ * @returns {boolean} - True if the menu should close
244
+ */
245
+ shouldCloseOnClickOutside(event) {
246
+ // Default: close if clicking outside the entire element
247
+ return !this.element.contains(event.target)
248
+ }
249
+
250
+ // Private helpers
251
+ // Note: click outside is handled by stimulus-use's useClickOutside
252
+ addEventListeners() {
253
+ document.addEventListener("keydown", this.boundHandleKeydown)
254
+ }
255
+
256
+ removeEventListeners() {
257
+ document.removeEventListener("keydown", this.boundHandleKeydown)
258
+ }
259
+
260
+ cancelHideTimeout() {
261
+ if (this.hideTimeoutId) {
262
+ clearTimeout(this.hideTimeoutId)
263
+ this.hideTimeoutId = null
264
+ }
265
+ }
266
+ }
@@ -1,23 +1,43 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { useClickOutside, useDebounce } from "stimulus-use"
3
+ import { positionFloating } from "../utils/floating"
2
4
 
3
5
  /**
4
6
  * Combobox controller for searchable select dropdown
5
7
  * Handles open/close, filtering, keyboard navigation, and item selection
8
+ * Uses Floating UI for smart positioning and stimulus-use for utilities
6
9
  */
7
10
  export default class extends Controller {
8
11
  static targets = ["trigger", "content", "input", "list", "item", "empty", "displayValue", "hiddenInput"]
9
12
  static values = {
10
13
  open: { type: Boolean, default: false },
11
14
  value: { type: String, default: "" },
12
- selectedIndex: { type: Number, default: -1 }
15
+ selectedIndex: { type: Number, default: -1 },
16
+ debounceWait: { type: Number, default: 150 },
17
+ placement: { type: String, default: "bottom-start" }
13
18
  }
19
+ static debounces = ["filter"]
14
20
 
15
21
  connect() {
16
22
  this.boundHandleKeydown = this.handleKeydown.bind(this)
23
+ this.cleanupFloating = null
24
+
25
+ // Use stimulus-use for click outside detection
26
+ useClickOutside(this)
27
+ // Use stimulus-use for debounced filtering
28
+ useDebounce(this, { wait: this.debounceWaitValue })
17
29
  }
18
30
 
19
31
  disconnect() {
20
32
  document.removeEventListener("keydown", this.boundHandleKeydown)
33
+ this.cleanupPositioning()
34
+ }
35
+
36
+ cleanupPositioning() {
37
+ if (this.cleanupFloating) {
38
+ this.cleanupFloating()
39
+ this.cleanupFloating = null
40
+ }
21
41
  }
22
42
 
23
43
  toggle() {
@@ -36,6 +56,13 @@ export default class extends Controller {
36
56
  this.contentTarget.dataset.state = "open"
37
57
  this.triggerTarget.setAttribute("aria-expanded", "true")
38
58
 
59
+ // Use Floating UI for smart positioning
60
+ this.cleanupFloating = positionFloating(this.triggerTarget, this.contentTarget, {
61
+ placement: this.placementValue,
62
+ sameWidth: true,
63
+ maxHeight: 384 // max-h-96
64
+ })
65
+
39
66
  // Focus the input
40
67
  requestAnimationFrame(() => {
41
68
  if (this.hasInputTarget) {
@@ -58,6 +85,9 @@ export default class extends Controller {
58
85
  this.contentTarget.dataset.state = "closed"
59
86
  this.triggerTarget.setAttribute("aria-expanded", "false")
60
87
 
88
+ // Cleanup Floating UI
89
+ this.cleanupPositioning()
90
+
61
91
  // Hide after animation completes, then reset filter state
62
92
  const hideAndReset = () => {
63
93
  this.contentTarget.hidden = true
@@ -221,13 +251,9 @@ export default class extends Controller {
221
251
  return this.itemTargets.filter((item) => item.style.display !== "none")
222
252
  }
223
253
 
224
- /**
225
- * Handle click outside to close
226
- */
227
- handleClickOutside(event) {
228
- if (!this.openValue) return
229
-
230
- if (!this.element.contains(event.target)) {
254
+ // Called by stimulus-use when clicking outside the element
255
+ clickOutside(event) {
256
+ if (this.openValue) {
231
257
  this.close()
232
258
  }
233
259
  }
@@ -1,4 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { useDebounce } from "stimulus-use"
2
3
 
3
4
  /**
4
5
  * Command controller for command palette functionality
@@ -7,10 +8,13 @@ import { Controller } from "@hotwired/stimulus"
7
8
  export default class extends Controller {
8
9
  static targets = ["input", "list", "empty", "group", "item"]
9
10
  static values = {
10
- selectedIndex: { type: Number, default: -1 }
11
+ selectedIndex: { type: Number, default: -1 },
12
+ debounceWait: { type: Number, default: 150 }
11
13
  }
14
+ static debounces = ["filter"]
12
15
 
13
16
  connect() {
17
+ useDebounce(this, { wait: this.debounceWaitValue })
14
18
  this.updateSelection()
15
19
  }
16
20
 
@@ -1,43 +1,66 @@
1
- import { Controller } from "@hotwired/stimulus"
1
+ import BaseMenuController from "./base_menu_controller"
2
+ import { positionAtPoint } from "../utils/floating"
2
3
 
3
4
  /**
4
5
  * Context Menu controller for right-click menus
5
- * Handles opening at mouse position, closing, keyboard navigation, and item selection
6
+ * Extends BaseMenuController with Floating UI positioning at cursor location
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,
12
+ hideDelay: { type: Number, default: 100 }
11
13
  }
12
14
 
13
15
  connect() {
14
- this.focusedIndex = -1
15
- this.boundHandleClickOutside = this.handleClickOutside.bind(this)
16
- this.boundHandleKeydown = this.handleKeydown.bind(this)
16
+ super.connect()
17
+ this.boundHandleContextMenu = this.handleContextMenu.bind(this)
18
+ this.originalOverflow = null
19
+ this.mouseX = 0
20
+ this.mouseY = 0
21
+ this._ignoreClickOutside = false
17
22
  }
18
23
 
19
- disconnect() {
20
- this.hide()
24
+ // Override clickOutside to handle the deferred close behavior
25
+ // Context menus need to ignore clicks in the same frame as the right-click
26
+ clickOutside(event) {
27
+ if (this._ignoreClickOutside) return
28
+ super.clickOutside(event)
21
29
  }
22
30
 
23
31
  show(event) {
24
32
  event?.preventDefault()
25
33
 
34
+ // Cancel any pending hide timeout from a previous close
35
+ this.cancelHideTimeout()
36
+
26
37
  // Store mouse position for positioning
27
38
  this.mouseX = event?.clientX || 0
28
39
  this.mouseY = event?.clientY || 0
29
40
 
30
41
  this.openValue = true
31
42
 
43
+ // Lock scroll (only if not already locked)
44
+ if (document.body.style.overflow !== "hidden") {
45
+ this.originalOverflow = document.body.style.overflow
46
+ document.body.style.overflow = "hidden"
47
+ }
48
+
32
49
  if (this.hasContentTarget) {
33
50
  this.contentTarget.hidden = false
34
51
  this.contentTarget.dataset.state = "open"
35
52
  this.positionContent()
36
53
  }
37
54
 
38
- // Add event listeners
39
- document.addEventListener("click", this.boundHandleClickOutside)
40
- document.addEventListener("contextmenu", this.boundHandleClickOutside)
55
+ // Defer click outside detection to prevent immediate close from right-click
56
+ // The contextmenu event can sometimes trigger a click in the same event cycle
57
+ this._ignoreClickOutside = true
58
+ requestAnimationFrame(() => {
59
+ this._ignoreClickOutside = false
60
+ if (this.openValue) {
61
+ document.addEventListener("contextmenu", this.boundHandleContextMenu)
62
+ }
63
+ })
41
64
  document.addEventListener("keydown", this.boundHandleKeydown)
42
65
 
43
66
  // Focus first item
@@ -52,151 +75,57 @@ export default class extends Controller {
52
75
 
53
76
  this.openValue = false
54
77
 
78
+ // Remove event listeners immediately to prevent double-triggering
79
+ document.removeEventListener("contextmenu", this.boundHandleContextMenu)
80
+ document.removeEventListener("keydown", this.boundHandleKeydown)
81
+
55
82
  if (this.hasContentTarget) {
56
83
  this.contentTarget.dataset.state = "closed"
57
- // Hide after animation
58
- setTimeout(() => {
84
+ // Wait for animation to complete before hiding and restoring scroll
85
+ // Animation duration is 100ms, add buffer for smooth transition
86
+ this.hideTimeoutId = setTimeout(() => {
59
87
  if (!this.openValue) {
60
88
  this.contentTarget.hidden = true
89
+ // Restore scroll only after menu is fully hidden
90
+ document.body.style.overflow = this.originalOverflow || ""
61
91
  }
62
- }, 150)
92
+ this.hideTimeoutId = null
93
+ }, this.hideDelayValue)
94
+ } else {
95
+ // No content target, restore scroll immediately
96
+ document.body.style.overflow = this.originalOverflow || ""
63
97
  }
64
98
 
65
- // Remove event listeners
66
- document.removeEventListener("click", this.boundHandleClickOutside)
67
- document.removeEventListener("contextmenu", this.boundHandleClickOutside)
68
- document.removeEventListener("keydown", this.boundHandleKeydown)
69
-
70
99
  // Reset focus index
71
100
  this.focusedIndex = -1
72
101
 
73
102
  this.dispatch("closed")
74
103
  }
75
104
 
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)) {
105
+ handleContextMenu(event) {
106
+ // Don't close if right-clicking on the trigger element
107
+ // This allows show() to be called again to reposition the menu
108
+ if (this.hasTriggerTarget && this.triggerTarget.contains(event.target)) {
91
109
  return
92
110
  }
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
111
+ // Close if right-clicking outside the content
112
+ if (this.hasContentTarget && !this.contentTarget.contains(event.target)) {
113
+ this.hide()
122
114
  }
123
115
  }
124
116
 
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()
117
+ shouldCloseOnClickOutside(event) {
118
+ // Don't close if clicking inside the content
119
+ if (this.hasContentTarget && this.contentTarget.contains(event.target)) {
120
+ return false
161
121
  }
162
- }
163
-
164
- get enabledItems() {
165
- return this.itemTargets.filter(item => item.dataset.disabled === undefined)
122
+ return true
166
123
  }
167
124
 
168
125
  positionContent() {
169
126
  if (!this.hasContentTarget) return
170
127
 
171
- const content = this.contentTarget
172
- const viewportWidth = window.innerWidth
173
- const viewportHeight = window.innerHeight
174
-
175
- // Reset position to measure actual size
176
- content.style.left = "0"
177
- content.style.top = "0"
178
-
179
- const contentRect = content.getBoundingClientRect()
180
-
181
- // Calculate position, keeping menu within viewport
182
- let x = this.mouseX
183
- let y = this.mouseY
184
-
185
- // Adjust if menu would overflow right edge
186
- if (x + contentRect.width > viewportWidth) {
187
- x = viewportWidth - contentRect.width - 8
188
- }
189
-
190
- // Adjust if menu would overflow bottom edge
191
- if (y + contentRect.height > viewportHeight) {
192
- y = viewportHeight - contentRect.height - 8
193
- }
194
-
195
- // Ensure menu doesn't go off left or top edge
196
- x = Math.max(8, x)
197
- y = Math.max(8, y)
198
-
199
- content.style.left = `${x}px`
200
- content.style.top = `${y}px`
128
+ // Use Floating UI for smart positioning at cursor location
129
+ positionAtPoint(this.contentTarget, this.mouseX, this.mouseY)
201
130
  }
202
131
  }