spree_admin 5.2.0.rc2 → 5.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 (188) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/spree/admin/application.scss +1 -1
  3. data/app/assets/stylesheets/spree/admin/components/_alerts.scss +1 -1
  4. data/app/assets/stylesheets/spree/admin/components/_buttons.scss +5 -4
  5. data/app/assets/stylesheets/spree/admin/components/_dialogs.scss +0 -1
  6. data/app/assets/stylesheets/spree/admin/components/_dropdowns.scss +6 -0
  7. data/app/assets/stylesheets/spree/admin/components/_main.scss +2 -233
  8. data/app/assets/stylesheets/spree/admin/components/_sidebar.scss +693 -0
  9. data/app/assets/stylesheets/spree/admin/components/_tables.scss +2 -2
  10. data/app/assets/stylesheets/spree/admin/components/_variants_form.scss +1 -2
  11. data/app/assets/stylesheets/spree/admin/global/_variables.scss +15 -12
  12. data/app/assets/stylesheets/spree/admin/shared/_base.scss +9 -3
  13. data/app/assets/stylesheets/spree/admin/shared/_forms.scss +5 -6
  14. data/app/assets/stylesheets/spree/admin/views/_dashboard.scss +14 -0
  15. data/app/controllers/spree/admin/admin_users_controller.rb +0 -2
  16. data/app/controllers/spree/admin/checkouts_controller.rb +1 -4
  17. data/app/controllers/spree/admin/coupon_codes_controller.rb +0 -14
  18. data/app/controllers/spree/admin/customer_returns_controller.rb +0 -13
  19. data/app/controllers/spree/admin/digital_assets_controller.rb +2 -2
  20. data/app/controllers/spree/admin/exports_controller.rb +2 -9
  21. data/app/controllers/spree/admin/gift_cards_controller.rb +7 -14
  22. data/app/controllers/spree/admin/integrations_controller.rb +1 -1
  23. data/app/controllers/spree/admin/invitations_controller.rb +0 -2
  24. data/app/controllers/spree/admin/metafields_controller.rb +1 -1
  25. data/app/controllers/spree/admin/oauth_applications_controller.rb +0 -10
  26. data/app/controllers/spree/admin/option_types_controller.rb +0 -10
  27. data/app/controllers/spree/admin/orders_controller.rb +1 -1
  28. data/app/controllers/spree/admin/page_blocks_controller.rb +1 -1
  29. data/app/controllers/spree/admin/pages_controller.rb +1 -1
  30. data/app/controllers/spree/admin/payment_methods_controller.rb +1 -11
  31. data/app/controllers/spree/admin/policies_controller.rb +4 -0
  32. data/app/controllers/spree/admin/posts_controller.rb +2 -10
  33. data/app/controllers/spree/admin/promotion_actions_controller.rb +1 -1
  34. data/app/controllers/spree/admin/promotion_rules_controller.rb +1 -1
  35. data/app/controllers/spree/admin/promotions_controller.rb +1 -1
  36. data/app/controllers/spree/admin/properties_controller.rb +0 -12
  37. data/app/controllers/spree/admin/reports_controller.rb +1 -1
  38. data/app/controllers/spree/admin/resource_controller.rb +27 -17
  39. data/app/controllers/spree/admin/return_authorizations_controller.rb +0 -10
  40. data/app/controllers/spree/admin/shipping_methods_controller.rb +4 -0
  41. data/app/controllers/spree/admin/stock_items_controller.rb +8 -11
  42. data/app/controllers/spree/admin/stock_locations_controller.rb +1 -1
  43. data/app/controllers/spree/admin/stock_transfers_controller.rb +0 -10
  44. data/app/controllers/spree/admin/store_credits_controller.rb +35 -35
  45. data/app/controllers/spree/admin/taxonomies_controller.rb +0 -10
  46. data/app/controllers/spree/admin/taxons_controller.rb +1 -1
  47. data/app/controllers/spree/admin/themes_controller.rb +6 -2
  48. data/app/controllers/spree/admin/translations_controller.rb +1 -1
  49. data/app/controllers/spree/admin/users_controller.rb +7 -17
  50. data/app/controllers/spree/admin/webhooks_subscribers_controller.rb +0 -10
  51. data/app/controllers/spree/admin/zones_controller.rb +0 -7
  52. data/app/helpers/spree/admin/base_helper.rb +1 -1
  53. data/app/helpers/spree/admin/drawer_helper.rb +6 -6
  54. data/app/helpers/spree/admin/dropdown_helper.rb +26 -16
  55. data/app/helpers/spree/admin/modal_helper.rb +2 -0
  56. data/app/helpers/spree/admin/navigation_helper.rb +47 -4
  57. data/app/helpers/spree/admin/orders_filters_helper.rb +3 -0
  58. data/app/helpers/spree/admin/promotion_actions_helper.rb +1 -1
  59. data/app/helpers/spree/admin/promotion_rules_helper.rb +1 -1
  60. data/app/helpers/spree/admin/translations_helper.rb +1 -1
  61. data/app/javascript/spree/admin/application.js +2 -1
  62. data/app/javascript/spree/admin/controllers/dropdown_controller.js +85 -14
  63. data/app/javascript/spree/admin/controllers/sidebar_controller.js +231 -0
  64. data/app/javascript/spree/admin/controllers/tooltip_controller.js +84 -31
  65. data/app/models/spree/admin/form_builder.rb +76 -17
  66. data/app/models/spree/admin/navigation/builder.rb +82 -0
  67. data/app/models/spree/admin/navigation/item.rb +177 -0
  68. data/app/models/spree/admin/navigation.rb +193 -0
  69. data/app/views/layouts/spree/admin.html.erb +1 -1
  70. data/app/views/spree/admin/assets/edit.html.erb +1 -1
  71. data/app/views/spree/admin/custom_domains/_custom_domains.html.erb +1 -1
  72. data/app/views/spree/admin/custom_domains/_form.html.erb +2 -14
  73. data/app/views/spree/admin/digital_assets/_table.html.erb +1 -1
  74. data/app/views/spree/admin/gift_cards/_filters.html.erb +25 -16
  75. data/app/views/spree/admin/gift_cards/index.html.erb +1 -1
  76. data/app/views/spree/admin/integrations/index.html.erb +20 -8
  77. data/app/views/spree/admin/invitations/new.html.erb +2 -1
  78. data/app/views/spree/admin/metafield_definitions/_filters.html.erb +1 -1
  79. data/app/views/spree/admin/newsletter_subscribers/_filters.html.erb +1 -1
  80. data/app/views/spree/admin/newsletter_subscribers/_table_header.html.erb +2 -2
  81. data/app/views/spree/admin/oauth_applications/_table_header.html.erb +1 -1
  82. data/app/views/spree/admin/orders/_customer.html.erb +3 -3
  83. data/app/views/spree/admin/orders/_filters.html.erb +33 -25
  84. data/app/views/spree/admin/orders/_header.html.erb +0 -5
  85. data/app/views/spree/admin/orders/_list.html.erb +3 -3
  86. data/app/views/spree/admin/orders/_table_filter_dropdown.html.erb +1 -1
  87. data/app/views/spree/admin/page_blocks/edit.html.erb +3 -3
  88. data/app/views/spree/admin/page_blocks/forms/_image.html.erb +2 -5
  89. data/app/views/spree/admin/page_builder/_add_block.html.erb +1 -1
  90. data/app/views/spree/admin/page_builder/_header.html.erb +1 -1
  91. data/app/views/spree/admin/page_builder/_pages_dropdown.html.erb +2 -2
  92. data/app/views/spree/admin/page_builder/_sidebar_block.html.erb +1 -1
  93. data/app/views/spree/admin/page_builder/_sidebar_colors.html.erb +2 -2
  94. data/app/views/spree/admin/page_builder/_sidebar_fonts.html.erb +3 -3
  95. data/app/views/spree/admin/page_builder/_sidebar_section.html.erb +1 -1
  96. data/app/views/spree/admin/page_links/_form.html.erb +4 -13
  97. data/app/views/spree/admin/page_links/_list.html.erb +1 -1
  98. data/app/views/spree/admin/page_links/edit.html.erb +1 -1
  99. data/app/views/spree/admin/page_sections/edit.html.erb +3 -3
  100. data/app/views/spree/admin/page_sections/forms/_header.html.erb +0 -2
  101. data/app/views/spree/admin/page_sections/new.html.erb +1 -1
  102. data/app/views/spree/admin/pages/_table_header.html.erb +3 -3
  103. data/app/views/spree/admin/payment_methods/index.html.erb +5 -1
  104. data/app/views/spree/admin/payments/_payment.html.erb +7 -0
  105. data/app/views/spree/admin/policies/_filters.html.erb +1 -1
  106. data/app/views/spree/admin/posts/_form.html.erb +1 -4
  107. data/app/views/spree/admin/posts/filters.html.erb +18 -8
  108. data/app/views/spree/admin/products/_bulk_operations.html.erb +2 -2
  109. data/app/views/spree/admin/products/_filters.html.erb +17 -6
  110. data/app/views/spree/admin/products/_table_filter_dropdown.html.erb +1 -1
  111. data/app/views/spree/admin/products/edit.html.erb +0 -2
  112. data/app/views/spree/admin/products/form/_status.html.erb +0 -3
  113. data/app/views/spree/admin/products/form/_variants.html.erb +1 -1
  114. data/app/views/spree/admin/profile/edit.html.erb +9 -61
  115. data/app/views/spree/admin/promotions/_filters.html.erb +23 -13
  116. data/app/views/spree/admin/promotions/_table_filter_dropdown.html.erb +1 -1
  117. data/app/views/spree/admin/promotions/_table_header.html.erb +1 -1
  118. data/app/views/spree/admin/promotions/form/_kind.html.erb +4 -4
  119. data/app/views/spree/admin/promotions/form/_settings.html.erb +2 -13
  120. data/app/views/spree/admin/refund_reasons/_table_header.html.erb +1 -1
  121. data/app/views/spree/admin/refunds/_form.html.erb +1 -9
  122. data/app/views/spree/admin/reimbursement_types/_table_header.html.erb +1 -1
  123. data/app/views/spree/admin/return_authorization_reasons/_table_header.html.erb +1 -1
  124. data/app/views/spree/admin/return_authorizations/filters.html.erb +1 -1
  125. data/app/views/spree/admin/roles/index.html.erb +1 -1
  126. data/app/views/spree/admin/shared/_audit_nav.html.erb +2 -0
  127. data/app/views/spree/admin/shared/_calendar_range_picker.html.erb +2 -2
  128. data/app/views/spree/admin/shared/_content_header.html.erb +5 -2
  129. data/app/views/spree/admin/shared/_developers_nav.html.erb +2 -4
  130. data/app/views/spree/admin/shared/_header.html.erb +5 -7
  131. data/app/views/spree/admin/shared/_index_table.html.erb +5 -4
  132. data/app/views/spree/admin/shared/_index_table_options.html.erb +1 -1
  133. data/app/views/spree/admin/shared/_navigation.html.erb +5 -0
  134. data/app/views/spree/admin/shared/_navigation_item.html.erb +64 -0
  135. data/app/views/spree/admin/shared/_new_item_dropdown.html.erb +2 -2
  136. data/app/views/spree/admin/shared/_page_section_image.html.erb +2 -5
  137. data/app/views/spree/admin/shared/_page_section_logo.html.erb +1 -1
  138. data/app/views/spree/admin/shared/_returns_and_refunds_nav.html.erb +2 -3
  139. data/app/views/spree/admin/shared/_shipping_nav.html.erb +3 -2
  140. data/app/views/spree/admin/shared/_sidebar.html.erb +33 -7
  141. data/app/views/spree/admin/shared/_stock_nav.html.erb +6 -3
  142. data/app/views/spree/admin/shared/_tax_nav.html.erb +1 -2
  143. data/app/views/spree/admin/shared/_team_nav.html.erb +2 -3
  144. data/app/views/spree/admin/shared/_user_dropdown.html.erb +29 -19
  145. data/app/views/spree/admin/shared/sidebar/_customers_nav.html.erb +7 -0
  146. data/app/views/spree/admin/shared/sidebar/_orders_nav.html.erb +22 -2
  147. data/app/views/spree/admin/shared/sidebar/_products_nav.html.erb +21 -0
  148. data/app/views/spree/admin/shared/sidebar/_promotions_nav.html.erb +8 -0
  149. data/app/views/spree/admin/shared/sidebar/_returns_nav.html.erb +12 -0
  150. data/app/views/spree/admin/shared/sidebar/_store_dropdown.html.erb +4 -2
  151. data/app/views/spree/admin/shared/sidebar/_store_nav.html.erb +15 -1
  152. data/app/views/spree/admin/shared/sidebar/_storefront_nav.html.erb +25 -3
  153. data/app/views/spree/admin/shared/sortable_tree/_taxonomy.html.erb +1 -1
  154. data/app/views/spree/admin/shipping_categories/_table_header.html.erb +1 -1
  155. data/app/views/spree/admin/shipping_methods/_table_header.html.erb +1 -1
  156. data/app/views/spree/admin/stock_items/_filters.html.erb +18 -8
  157. data/app/views/spree/admin/stock_locations/_table_header.html.erb +2 -2
  158. data/app/views/spree/admin/stock_locations/_table_row.html.erb +1 -1
  159. data/app/views/spree/admin/stock_transfers/_filters.html.erb +19 -9
  160. data/app/views/spree/admin/store_credit_categories/index.html.erb +1 -1
  161. data/app/views/spree/admin/store_credits/_list.html.erb +3 -3
  162. data/app/views/spree/admin/storefront/edit.html.erb +2 -14
  163. data/app/views/spree/admin/stores/form/_basic.html.erb +2 -8
  164. data/app/views/spree/admin/stores/form/_checkout.html.erb +2 -2
  165. data/app/views/spree/admin/stores/form/_checkout_links.html.erb +1 -1
  166. data/app/views/spree/admin/stores/form/_emails.html.erb +1 -1
  167. data/app/views/spree/admin/tax_categories/_table_header.html.erb +2 -2
  168. data/app/views/spree/admin/tax_rates/_form.html.erb +1 -10
  169. data/app/views/spree/admin/tax_rates/_table_header.html.erb +2 -2
  170. data/app/views/spree/admin/taxonomies/_table_header.html.erb +1 -1
  171. data/app/views/spree/admin/taxons/_form.html.erb +2 -9
  172. data/app/views/spree/admin/themes/_theme.html.erb +1 -1
  173. data/app/views/spree/admin/translations/translation_rows/_permalink_field_row.html.erb +1 -12
  174. data/app/views/spree/admin/users/_filters.html.erb +26 -17
  175. data/app/views/spree/admin/users/index.html.erb +1 -1
  176. data/config/initializers/spree_admin_navigation.rb +510 -0
  177. data/config/locales/en.yml +6 -0
  178. data/lib/generators/spree/admin/scaffold/templates/controller.rb.tt +3 -1
  179. data/lib/generators/spree/admin/scaffold/templates/views/_filters.html.erb.tt +1 -1
  180. data/lib/generators/spree/admin/scaffold/templates/views/_table_header.html.erb.tt +2 -2
  181. data/lib/generators/spree/admin/scaffold/templates/views/_table_row.html.erb.tt +8 -6
  182. data/lib/spree/admin/engine.rb +64 -2
  183. data/lib/spree/admin/runtime_configuration.rb +1 -0
  184. data/lib/spree/admin.rb +20 -0
  185. metadata +17 -15
  186. data/app/assets/stylesheets/spree/admin/components/_offcanvas.scss +0 -26
  187. data/app/javascript/spree/admin/helpers/canvas.js +0 -29
  188. data/app/views/spree/admin/shared/_offcanvas_nav.html.erb +0 -12
@@ -9,24 +9,44 @@ import {
9
9
  import { Controller } from "@hotwired/stimulus"
10
10
 
11
11
  export default class extends Controller {
12
- static targets = ["menu", "toggle"]
13
12
  static values = {
14
13
  placement: { type: String, default: "bottom-start" },
15
14
  offset: { type: Number, default: 4 },
15
+ portal: { type: Boolean, default: true },
16
16
  }
17
17
 
18
18
  connect() {
19
+ // Find menu and toggle elements by CSS class instead of Stimulus target
20
+ // Try both .dropdown-menu and .dropdown-container for backward compatibility
21
+ this.menu = this.element.querySelector('.dropdown-menu') ||
22
+ this.element.querySelector('.dropdown-container')
23
+ this.toggleBtn = this.element.querySelector('.dropdown-toggle')
24
+
25
+ // Early return if no menu element exists
26
+ if (!this.menu) {
27
+ return
28
+ }
29
+
19
30
  this._cleanup = null
20
31
  this.boundUpdate = this.update.bind(this)
21
32
  this._isOpen = false
22
33
  this._toggleElement = null
34
+ this._originalParent = null
35
+ this._originalNextSibling = null
36
+ this._movedToBody = false
23
37
  }
24
38
 
25
39
  disconnect() {
40
+ if (!this.menu) return
26
41
  this.stopAutoUpdate()
42
+ this.restoreMenuPosition()
27
43
  }
28
44
 
29
45
  toggle(event) {
46
+ if (!this.menu) {
47
+ return
48
+ }
49
+
30
50
  event.preventDefault()
31
51
  event.stopPropagation()
32
52
 
@@ -41,9 +61,19 @@ export default class extends Controller {
41
61
  }
42
62
 
43
63
  open() {
44
- if (this._isOpen) return
64
+ if (!this.menu || this._isOpen) {
65
+ return
66
+ }
45
67
 
46
- this.menuTarget.classList.remove("hidden")
68
+ // Move menu to body on first open to prevent clipping by sidebar overflow
69
+ // Skip if portal is disabled or if inside bulk panel (to preserve Stimulus controller context)
70
+ if (!this._movedToBody && this.shouldPortalToBody()) {
71
+ this.moveMenuToBody()
72
+ this._movedToBody = true
73
+ }
74
+
75
+ this.menu.classList.remove("hidden")
76
+ this.menu.style.display = "block"
47
77
  this._isOpen = true
48
78
 
49
79
  // Start automatic positioning
@@ -59,9 +89,10 @@ export default class extends Controller {
59
89
  }
60
90
 
61
91
  close() {
62
- if (!this._isOpen) return
92
+ if (!this.menu || !this._isOpen) return
63
93
 
64
- this.menuTarget.classList.add("hidden")
94
+ this.menu.classList.add("hidden")
95
+ this.menu.style.display = ""
65
96
  this._isOpen = false
66
97
 
67
98
  // Stop automatic positioning
@@ -101,11 +132,11 @@ export default class extends Controller {
101
132
  }
102
133
 
103
134
  startAutoUpdate() {
104
- if (!this._cleanup) {
105
- const referenceElement = this.hasToggleTarget ? this.toggleTarget : (this._toggleElement || this.element)
135
+ if (!this._cleanup && this.menu) {
136
+ const referenceElement = this.toggleBtn || this._toggleElement || this.element
106
137
  this._cleanup = autoUpdate(
107
138
  referenceElement,
108
- this.menuTarget,
139
+ this.menu,
109
140
  this.boundUpdate,
110
141
  )
111
142
  }
@@ -119,10 +150,12 @@ export default class extends Controller {
119
150
  }
120
151
 
121
152
  update() {
122
- // Use the toggle target if available, or the stored toggle element, or fall back to the controller element
123
- const referenceElement = this.hasToggleTarget ? this.toggleTarget : (this._toggleElement || this.element)
153
+ if (!this.menu) return
154
+
155
+ // Use the toggle button if available, or the stored toggle element, or fall back to the controller element
156
+ const referenceElement = this.toggleBtn || this._toggleElement || this.element
124
157
 
125
- computePosition(referenceElement, this.menuTarget, {
158
+ computePosition(referenceElement, this.menu, {
126
159
  placement: this.placementValue,
127
160
  middleware: [
128
161
  offset(this.offsetValue),
@@ -133,9 +166,18 @@ export default class extends Controller {
133
166
  shift({ padding: 8 }),
134
167
  size({
135
168
  apply({ availableWidth, availableHeight, elements }) {
136
- // Ensure dropdown doesn't exceed viewport
169
+ // Get the element's computed max-width
170
+ const computedStyle = window.getComputedStyle(elements.floating)
171
+ const originalMaxWidth = parseFloat(computedStyle.maxWidth)
172
+
173
+ // Use the smaller of availableWidth or original max-width
174
+ const maxWidth = originalMaxWidth && !isNaN(originalMaxWidth)
175
+ ? Math.min(availableWidth, originalMaxWidth)
176
+ : availableWidth
177
+
178
+ // Ensure dropdown doesn't exceed viewport or original constraints
137
179
  Object.assign(elements.floating.style, {
138
- maxWidth: `${availableWidth}px`,
180
+ maxWidth: `${maxWidth}px`,
139
181
  maxHeight: `${availableHeight}px`,
140
182
  overflow: "auto",
141
183
  })
@@ -144,11 +186,40 @@ export default class extends Controller {
144
186
  }),
145
187
  ],
146
188
  }).then(({ x, y }) => {
147
- Object.assign(this.menuTarget.style, {
189
+ Object.assign(this.menu.style, {
148
190
  left: `${x}px`,
149
191
  top: `${y}px`,
150
192
  position: "absolute",
151
193
  })
152
194
  })
153
195
  }
196
+
197
+ shouldPortalToBody() {
198
+ // Don't portal if explicitly disabled via data attribute
199
+ return this.portalValue
200
+ }
201
+
202
+ moveMenuToBody() {
203
+ if (this.menu && this.menu.parentNode !== document.body) {
204
+ // Save original position for restoration
205
+ this._originalParent = this.menu.parentNode
206
+ this._originalNextSibling = this.menu.nextSibling
207
+
208
+ // Move menu to body to prevent clipping by sidebar overflow
209
+ document.body.appendChild(this.menu)
210
+ }
211
+ }
212
+
213
+ restoreMenuPosition() {
214
+ if (this.menu && this._originalParent) {
215
+ // Restore menu to original position
216
+ if (this._originalNextSibling) {
217
+ this._originalParent.insertBefore(this.menu, this._originalNextSibling)
218
+ } else {
219
+ this._originalParent.appendChild(this.menu)
220
+ }
221
+ this._originalParent = null
222
+ this._originalNextSibling = null
223
+ }
224
+ }
154
225
  }
@@ -0,0 +1,231 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import {
3
+ computePosition,
4
+ flip,
5
+ shift,
6
+ offset,
7
+ autoUpdate,
8
+ } from "@floating-ui/dom";
9
+
10
+ export default class extends Controller {
11
+ static targets = ["desktop", "mobile"];
12
+
13
+ static values = {
14
+ storageKey: { type: String, default: "spree_admin_sidebar_open" },
15
+ };
16
+
17
+ connect() {
18
+ // Initialize dropdown tracking
19
+ this.activeDropdown = null
20
+ this.dropdownCleanup = null
21
+ this.submenuHoverHandlers = new Map()
22
+ this.hideTimeout = null
23
+
24
+ // Disable transitions during initial load to prevent animation flash
25
+ this.element.classList.add("sidebar-no-transition");
26
+
27
+ // Restore saved state for desktop sidebar (default is open/expanded)
28
+ const savedState = localStorage.getItem(this.storageKeyValue);
29
+
30
+ // Only collapse if explicitly saved as false
31
+ if (savedState === "false") {
32
+ this.element.classList.add("sidebar-collapsed");
33
+ this.setupCollapsedSubmenuHandlers();
34
+ } else {
35
+ // Explicitly ensure it's not collapsed (default is expanded)
36
+ this.element.classList.remove("sidebar-collapsed");
37
+ }
38
+
39
+ // Re-enable transitions after initial state is set
40
+ requestAnimationFrame(() => {
41
+ this.element.classList.remove("sidebar-no-transition");
42
+ });
43
+ }
44
+
45
+ disconnect() {
46
+ // Clear any pending hide timeout
47
+ if (this.hideTimeout) {
48
+ clearTimeout(this.hideTimeout);
49
+ this.hideTimeout = null;
50
+ }
51
+
52
+ this.cleanupSubmenuHandlers();
53
+ if (this.dropdownCleanup) {
54
+ this.dropdownCleanup();
55
+ this.dropdownCleanup = null;
56
+ }
57
+ }
58
+
59
+ toggle() {
60
+ this.element.classList.toggle("sidebar-collapsed");
61
+ const isCollapsed = this.element.classList.contains("sidebar-collapsed");
62
+ localStorage.setItem(this.storageKeyValue, String(!isCollapsed));
63
+
64
+ // Setup or cleanup submenu handlers based on state
65
+ if (isCollapsed) {
66
+ this.setupCollapsedSubmenuHandlers();
67
+ } else {
68
+ this.cleanupSubmenuHandlers();
69
+ }
70
+ }
71
+
72
+ openMobile() {
73
+ if (this.hasMobileTarget) {
74
+ this.mobileTarget.classList.add("sidebar-mobile-open");
75
+ }
76
+ }
77
+
78
+ closeMobile() {
79
+ if (this.hasMobileTarget) {
80
+ this.mobileTarget.classList.remove("sidebar-mobile-open");
81
+ }
82
+ }
83
+
84
+ setupCollapsedSubmenuHandlers() {
85
+ const sidebar = document.querySelector('#main-sidebar');
86
+ if (!sidebar) return;
87
+
88
+ // Find all dropdown submenus
89
+ const dropdowns = sidebar.querySelectorAll('.nav-submenu-dropdown');
90
+
91
+ dropdowns.forEach(dropdown => {
92
+ // Find the associated nav-item (previous sibling, skipping the regular nav-submenu)
93
+ let navItem = dropdown.previousElementSibling;
94
+
95
+ // Skip over the regular .nav-submenu to get to the .nav-item
96
+ while (navItem && navItem.classList.contains('nav-submenu')) {
97
+ navItem = navItem.previousElementSibling;
98
+ }
99
+
100
+ if (!navItem || !navItem.classList.contains('nav-item')) return;
101
+
102
+ const showHandler = () => this.showSubmenuFloating(navItem, dropdown);
103
+ const hideHandler = () => this.scheduleHideSubmenu();
104
+
105
+ navItem.addEventListener('mouseenter', showHandler);
106
+ navItem.addEventListener('mouseleave', hideHandler);
107
+ dropdown.addEventListener('mouseenter', showHandler);
108
+ dropdown.addEventListener('mouseleave', hideHandler);
109
+
110
+ this.submenuHoverHandlers.set(navItem, { showHandler, hideHandler, dropdown });
111
+ });
112
+ }
113
+
114
+ cleanupSubmenuHandlers() {
115
+ this.submenuHoverHandlers.forEach(({ showHandler, hideHandler, dropdown }, navItem) => {
116
+ navItem.removeEventListener('mouseenter', showHandler);
117
+ navItem.removeEventListener('mouseleave', hideHandler);
118
+ dropdown.removeEventListener('mouseenter', showHandler);
119
+ dropdown.removeEventListener('mouseleave', hideHandler);
120
+ });
121
+ this.submenuHoverHandlers.clear();
122
+
123
+ // Hide active dropdown if any
124
+ if (this.activeDropdown) {
125
+ this.hideSubmenuFloating();
126
+ }
127
+ }
128
+
129
+ showSubmenuFloating(navItem, dropdown) {
130
+ // Cancel any pending hide
131
+ if (this.hideTimeout) {
132
+ clearTimeout(this.hideTimeout);
133
+ this.hideTimeout = null;
134
+ }
135
+
136
+ // If showing same dropdown, return
137
+ if (this.activeDropdown === dropdown) {
138
+ return;
139
+ }
140
+
141
+ // Hide any currently active dropdown
142
+ if (this.activeDropdown) {
143
+ this.hideSubmenuFloating();
144
+ }
145
+
146
+ // Store reference
147
+ this.activeDropdown = dropdown;
148
+
149
+ // Move dropdown to body for proper positioning (to avoid sidebar overflow clipping)
150
+ if (dropdown.parentNode !== document.body) {
151
+ dropdown._originalParent = dropdown.parentNode;
152
+ dropdown._originalNextSibling = dropdown.nextSibling;
153
+ document.body.appendChild(dropdown);
154
+ }
155
+
156
+ // Show dropdown
157
+ dropdown.classList.remove('d-none');
158
+
159
+ // Style dropdown items
160
+ dropdown.querySelectorAll('.nav-link').forEach((item, index) => {
161
+ item.classList.add('dropdown-item');
162
+ if (index > 0) {
163
+ item.classList.add('mt-1');
164
+ }
165
+ });
166
+
167
+ // Position using Floating UI
168
+ if (this.dropdownCleanup) {
169
+ this.dropdownCleanup();
170
+ }
171
+
172
+ this.dropdownCleanup = autoUpdate(navItem, dropdown, () => {
173
+ computePosition(navItem, dropdown, {
174
+ placement: 'right-start',
175
+ middleware: [
176
+ offset(8),
177
+ flip(),
178
+ shift({ padding: 8 }),
179
+ ],
180
+ }).then(({ x, y }) => {
181
+ Object.assign(dropdown.style, {
182
+ left: `${x}px`,
183
+ top: `${y}px`,
184
+ });
185
+ });
186
+ });
187
+ }
188
+
189
+ scheduleHideSubmenu() {
190
+ // Clear any existing timeout
191
+ if (this.hideTimeout) {
192
+ clearTimeout(this.hideTimeout);
193
+ }
194
+
195
+ // Schedule hide with delay to allow mouse to move to submenu
196
+ this.hideTimeout = setTimeout(() => {
197
+ this.hideSubmenuFloating();
198
+ this.hideTimeout = null;
199
+ }, 150); // 150ms grace period
200
+ }
201
+
202
+ hideSubmenuFloating() {
203
+ if (!this.activeDropdown) return;
204
+
205
+ // Stop auto-update
206
+ if (this.dropdownCleanup) {
207
+ this.dropdownCleanup();
208
+ this.dropdownCleanup = null;
209
+ }
210
+
211
+ // Hide dropdown
212
+ this.activeDropdown.classList.add('d-none');
213
+
214
+ // Restore dropdown to original position in sidebar
215
+ if (this.activeDropdown._originalParent) {
216
+ if (this.activeDropdown._originalNextSibling) {
217
+ this.activeDropdown._originalParent.insertBefore(
218
+ this.activeDropdown,
219
+ this.activeDropdown._originalNextSibling
220
+ );
221
+ } else {
222
+ this.activeDropdown._originalParent.appendChild(this.activeDropdown);
223
+ }
224
+ this.activeDropdown._originalParent = null;
225
+ this.activeDropdown._originalNextSibling = null;
226
+ }
227
+
228
+ // Clear reference
229
+ this.activeDropdown = null;
230
+ }
231
+ }
@@ -8,7 +8,6 @@ import {
8
8
  import { Controller } from "@hotwired/stimulus"
9
9
 
10
10
  export default class extends Controller {
11
- static targets = ["tooltip"]
12
11
  static values = {
13
12
  placement: { type: String, default: "top" },
14
13
  offset: { type: Number, default: 10 },
@@ -17,18 +16,34 @@ export default class extends Controller {
17
16
  }
18
17
 
19
18
  connect() {
19
+ // Find tooltip element by CSS class instead of Stimulus target
20
+ this.tooltip = this.element.querySelector('.tooltip-container')
21
+
22
+ // Early return if no tooltip element exists
23
+ if (!this.tooltip) {
24
+ return
25
+ }
26
+
20
27
  this._cleanup = null
21
28
  this.boundUpdate = this.update.bind(this)
22
29
  this._originalWidth = null
23
30
  this._isShown = false
31
+ this._originalParent = null
32
+ this._originalNextSibling = null
33
+ this._movedToBody = false
24
34
  this.startAutoUpdate()
25
35
  this.addEventListeners()
26
36
  this.prepareTooltip()
27
37
  }
28
38
 
29
39
  disconnect() {
40
+ if (!this.tooltip) {
41
+ return
42
+ }
43
+
30
44
  this.removeEventListeners()
31
45
  this.stopAutoUpdate()
46
+ this.restoreTooltipPosition()
32
47
  this.resetTooltip()
33
48
  }
34
49
 
@@ -44,43 +59,75 @@ export default class extends Controller {
44
59
 
45
60
  prepareTooltip() {
46
61
  // Ensure tooltip is rendered offscreen but measurable
47
- if (this.tooltipTarget) {
62
+ if (this.tooltip) {
48
63
  // Save original display
49
- this._originalDisplay = this.tooltipTarget.style.display
64
+ this._originalDisplay = this.tooltip.style.display
50
65
  // Temporarily show tooltip offscreen to measure size
51
- this.tooltipTarget.style.visibility = "hidden"
52
- this.tooltipTarget.style.display = "block"
53
- this.tooltipTarget.style.left = "-9999px"
54
- this.tooltipTarget.style.top = "-9999px"
66
+ this.tooltip.style.visibility = "hidden"
67
+ this.tooltip.style.display = "block"
68
+ this.tooltip.style.left = "-9999px"
69
+ this.tooltip.style.top = "-9999px"
55
70
  // Force reflow and measure
56
- this._originalWidth = this.tooltipTarget.offsetWidth + 10
71
+ this._originalWidth = this.tooltip.offsetWidth + 10
57
72
  // Height is now dynamic, so we do not set or store it
58
- this.tooltipTarget.style.width = `${this._originalWidth}px`
59
- this.tooltipTarget.style.height = "" // Remove any fixed height
73
+ this.tooltip.style.width = `${this._originalWidth}px`
74
+ this.tooltip.style.height = "" // Remove any fixed height
60
75
  // Hide again
61
- this.tooltipTarget.style.display = "none"
62
- this.tooltipTarget.style.visibility = ""
63
- this.tooltipTarget.style.left = ""
64
- this.tooltipTarget.style.top = ""
76
+ this.tooltip.style.display = "none"
77
+ this.tooltip.style.visibility = ""
78
+ this.tooltip.style.left = ""
79
+ this.tooltip.style.top = ""
65
80
  }
66
81
  }
67
82
 
68
83
  resetTooltip() {
69
- if (this.tooltipTarget) {
70
- this.tooltipTarget.style.width = ""
71
- this.tooltipTarget.style.height = ""
72
- this.tooltipTarget.style.display = this._originalDisplay || ""
84
+ if (this.tooltip) {
85
+ this.tooltip.style.width = ""
86
+ this.tooltip.style.height = ""
87
+ this.tooltip.style.display = this._originalDisplay || ""
88
+ }
89
+ }
90
+
91
+ moveTooltipToBody() {
92
+ if (this.tooltip && this.tooltip.parentNode !== document.body) {
93
+ // Save original position for restoration
94
+ this._originalParent = this.tooltip.parentNode
95
+ this._originalNextSibling = this.tooltip.nextSibling
96
+
97
+ // Move tooltip to body to prevent clipping by sidebar overflow
98
+ document.body.appendChild(this.tooltip)
99
+ }
100
+ }
101
+
102
+ restoreTooltipPosition() {
103
+ if (this.tooltip && this._originalParent) {
104
+ // Restore tooltip to original position
105
+ if (this._originalNextSibling) {
106
+ this._originalParent.insertBefore(this.tooltip, this._originalNextSibling)
107
+ } else {
108
+ this._originalParent.appendChild(this.tooltip)
109
+ }
110
+ this._originalParent = null
111
+ this._originalNextSibling = null
73
112
  }
74
113
  }
75
114
 
76
115
  show = () => {
116
+ if (!this.tooltip) return
117
+
77
118
  if (!this._isShown) {
78
- this.tooltipTarget.style.display = "block"
79
- this.tooltipTarget.style.visibility = "visible"
119
+ // Move to body on first show
120
+ if (!this._movedToBody) {
121
+ this.moveTooltipToBody()
122
+ this._movedToBody = true
123
+ }
124
+
125
+ this.tooltip.style.display = "block"
126
+ this.tooltip.style.visibility = "visible"
80
127
  // Set explicit width, but let height be dynamic
81
128
  if (this._originalWidth) {
82
- this.tooltipTarget.style.width = `${this._originalWidth}px`
83
- this.tooltipTarget.style.height = ""
129
+ this.tooltip.style.width = `${this._originalWidth}px`
130
+ this.tooltip.style.height = ""
84
131
  }
85
132
  this.update() // Ensure immediate update when shown
86
133
  this._isShown = true
@@ -88,19 +135,21 @@ export default class extends Controller {
88
135
  }
89
136
 
90
137
  hide = () => {
138
+ if (!this.tooltip) return
139
+
91
140
  if (this._isShown) {
92
- this.tooltipTarget.style.display = "none"
93
- this.tooltipTarget.style.visibility = ""
141
+ this.tooltip.style.display = "none"
142
+ this.tooltip.style.visibility = ""
94
143
  // Keep width set to prevent flicker if quickly re-hovered
95
144
  this._isShown = false
96
145
  }
97
146
  }
98
147
 
99
148
  startAutoUpdate() {
100
- if (!this._cleanup) {
149
+ if (!this._cleanup && this.tooltip) {
101
150
  this._cleanup = autoUpdate(
102
151
  this.element,
103
- this.tooltipTarget,
152
+ this.tooltip,
104
153
  this.boundUpdate,
105
154
  )
106
155
  }
@@ -114,8 +163,10 @@ export default class extends Controller {
114
163
  }
115
164
 
116
165
  update() {
166
+ if (!this.tooltip) return
167
+
117
168
  // Update position even if not visible, to ensure correct positioning when shown
118
- computePosition(this.element, this.tooltipTarget, {
169
+ computePosition(this.element, this.tooltip, {
119
170
  placement: this.placementValue,
120
171
  middleware: [
121
172
  offset({
@@ -127,10 +178,12 @@ export default class extends Controller {
127
178
  shift({ padding: 5 }),
128
179
  ],
129
180
  }).then(({ x, y }) => {
130
- Object.assign(this.tooltipTarget.style, {
131
- left: `${x}px`,
132
- top: `${y}px`,
133
- })
181
+ if (this.tooltip) {
182
+ Object.assign(this.tooltip.style, {
183
+ left: `${x}px`,
184
+ top: `${y}px`,
185
+ })
186
+ }
134
187
  })
135
188
  }
136
189
  }