rails_onboarding 0.6.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 (148) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +1351 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/rails_onboarding/admin/chart_controller.js +25 -0
  6. data/app/assets/javascripts/rails_onboarding/admin/filter_controller.js +49 -0
  7. data/app/assets/javascripts/rails_onboarding/admin/flash_controller.js +21 -0
  8. data/app/assets/javascripts/rails_onboarding/admin/flow_editor_controller.js +86 -0
  9. data/app/assets/javascripts/rails_onboarding/application.js +231 -0
  10. data/app/assets/javascripts/rails_onboarding/milestone_celebration_controller.js +177 -0
  11. data/app/assets/javascripts/rails_onboarding/milestone_dashboard_controller.js +132 -0
  12. data/app/assets/javascripts/rails_onboarding/milestone_detail_controller.js +34 -0
  13. data/app/assets/javascripts/rails_onboarding/navigation_controller.js +277 -0
  14. data/app/assets/javascripts/rails_onboarding/onboarding_controller.js +326 -0
  15. data/app/assets/javascripts/rails_onboarding/progress_controller.js +203 -0
  16. data/app/assets/javascripts/rails_onboarding/progressive_disclosure_controller.js +218 -0
  17. data/app/assets/javascripts/rails_onboarding/tooltip_controller.js +1237 -0
  18. data/app/assets/javascripts/rails_onboarding/tooltip_scheduler_controller.js +511 -0
  19. data/app/assets/javascripts/rails_onboarding/tour_controller.js +763 -0
  20. data/app/assets/stylesheets/rails_onboarding/accessibility.css +536 -0
  21. data/app/assets/stylesheets/rails_onboarding/admin.css +816 -0
  22. data/app/assets/stylesheets/rails_onboarding/application.css +1145 -0
  23. data/app/assets/stylesheets/rails_onboarding/flash_messages.css +237 -0
  24. data/app/assets/stylesheets/rails_onboarding/milestones.css +545 -0
  25. data/app/assets/stylesheets/rails_onboarding/mobile.css +856 -0
  26. data/app/assets/stylesheets/rails_onboarding/progressive_disclosure.css +299 -0
  27. data/app/assets/stylesheets/rails_onboarding/tooltips.css +468 -0
  28. data/app/assets/stylesheets/rails_onboarding/tour.css +495 -0
  29. data/app/assets/stylesheets/rails_onboarding/utilities.css +393 -0
  30. data/app/controllers/concerns/rails_onboarding/admin_authorization.rb +98 -0
  31. data/app/controllers/concerns/rails_onboarding/rate_limitable.rb +104 -0
  32. data/app/controllers/rails_onboarding/ab_tests_controller.rb +257 -0
  33. data/app/controllers/rails_onboarding/admin/ab_tests_controller.rb +121 -0
  34. data/app/controllers/rails_onboarding/admin/base_controller.rb +68 -0
  35. data/app/controllers/rails_onboarding/admin/dashboard_controller.rb +189 -0
  36. data/app/controllers/rails_onboarding/admin/flows_controller.rb +175 -0
  37. data/app/controllers/rails_onboarding/admin/users_controller.rb +366 -0
  38. data/app/controllers/rails_onboarding/application_controller.rb +9 -0
  39. data/app/controllers/rails_onboarding/milestones_controller.rb +126 -0
  40. data/app/controllers/rails_onboarding/onboarding_controller.rb +409 -0
  41. data/app/controllers/rails_onboarding/progressive_features_controller.rb +134 -0
  42. data/app/controllers/rails_onboarding/templates_controller.rb +162 -0
  43. data/app/controllers/rails_onboarding/test_sessions_controller.rb +16 -0
  44. data/app/controllers/rails_onboarding/tooltips_controller.rb +92 -0
  45. data/app/helpers/rails_onboarding/application_helper.rb +4 -0
  46. data/app/helpers/rails_onboarding/i18n_helper.rb +137 -0
  47. data/app/helpers/rails_onboarding/personalization_helper.rb +184 -0
  48. data/app/helpers/rails_onboarding/templates_helper.rb +253 -0
  49. data/app/jobs/rails_onboarding/application_job.rb +4 -0
  50. data/app/mailers/rails_onboarding/application_mailer.rb +6 -0
  51. data/app/mailers/rails_onboarding/onboarding_mailer.rb +72 -0
  52. data/app/models/concerns/rails_onboarding/ab_testable.rb +149 -0
  53. data/app/models/concerns/rails_onboarding/onboardable.rb +649 -0
  54. data/app/models/concerns/rails_onboarding/personalizable.rb +211 -0
  55. data/app/models/concerns/rails_onboarding/progressive_disclosure.rb +276 -0
  56. data/app/models/rails_onboarding/analytics.rb +255 -0
  57. data/app/models/rails_onboarding/analytics_event.rb +168 -0
  58. data/app/models/rails_onboarding/application_record.rb +5 -0
  59. data/app/models/rails_onboarding/flow.rb +41 -0
  60. data/app/services/rails_onboarding/milestone_service.rb +105 -0
  61. data/app/views/layouts/rails_onboarding/admin.html.erb +106 -0
  62. data/app/views/layouts/rails_onboarding/application.html.erb +65 -0
  63. data/app/views/rails_onboarding/ab_tests/index.html.erb +209 -0
  64. data/app/views/rails_onboarding/ab_tests/results.html.erb +253 -0
  65. data/app/views/rails_onboarding/admin/ab_tests/_ab_test_card.html.erb +24 -0
  66. data/app/views/rails_onboarding/admin/ab_tests/index.html.erb +57 -0
  67. data/app/views/rails_onboarding/admin/ab_tests/show.html.erb +41 -0
  68. data/app/views/rails_onboarding/admin/dashboard/index.html.erb +224 -0
  69. data/app/views/rails_onboarding/admin/flows/_form.html.erb +147 -0
  70. data/app/views/rails_onboarding/admin/flows/edit.html.erb +7 -0
  71. data/app/views/rails_onboarding/admin/flows/index.html.erb +125 -0
  72. data/app/views/rails_onboarding/admin/flows/new.html.erb +7 -0
  73. data/app/views/rails_onboarding/admin/flows/preview.html.erb +40 -0
  74. data/app/views/rails_onboarding/admin/flows/show.html.erb +114 -0
  75. data/app/views/rails_onboarding/admin/users/index.html.erb +180 -0
  76. data/app/views/rails_onboarding/admin/users/show.html.erb +187 -0
  77. data/app/views/rails_onboarding/milestones/index.html.erb +64 -0
  78. data/app/views/rails_onboarding/milestones/show.html.erb +57 -0
  79. data/app/views/rails_onboarding/onboarding/_custom_footer.html.erb +2 -0
  80. data/app/views/rails_onboarding/onboarding/_progress_indicator.html.erb +56 -0
  81. data/app/views/rails_onboarding/onboarding/_step_navigation.html.erb +44 -0
  82. data/app/views/rails_onboarding/onboarding/step.html.erb +38 -0
  83. data/app/views/rails_onboarding/onboarding/welcome.html.erb +37 -0
  84. data/app/views/rails_onboarding/onboarding_mailer/completion_email.html.erb +109 -0
  85. data/app/views/rails_onboarding/onboarding_mailer/completion_email.text.erb +29 -0
  86. data/app/views/rails_onboarding/onboarding_mailer/reminder_email.html.erb +99 -0
  87. data/app/views/rails_onboarding/onboarding_mailer/reminder_email.text.erb +20 -0
  88. data/app/views/rails_onboarding/onboarding_mailer/step_completed_email.html.erb +105 -0
  89. data/app/views/rails_onboarding/onboarding_mailer/step_completed_email.text.erb +19 -0
  90. data/app/views/rails_onboarding/onboarding_mailer/welcome_email.html.erb +74 -0
  91. data/app/views/rails_onboarding/onboarding_mailer/welcome_email.text.erb +17 -0
  92. data/app/views/rails_onboarding/shared/_flash.html.erb +25 -0
  93. data/app/views/rails_onboarding/shared/_milestone_badge.html.erb +22 -0
  94. data/app/views/rails_onboarding/shared/_milestone_celebration.html.erb +44 -0
  95. data/app/views/rails_onboarding/shared/_onboarding_banner.html.erb +48 -0
  96. data/app/views/rails_onboarding/templates/index.html.erb +255 -0
  97. data/config/importmap.rb +33 -0
  98. data/config/locales/en.yml +62 -0
  99. data/config/locales/es.yml +62 -0
  100. data/config/locales/fr.yml +62 -0
  101. data/config/routes.rb +79 -0
  102. data/lib/generators/rails_onboarding/install_generator.rb +124 -0
  103. data/lib/generators/rails_onboarding/templates/README +120 -0
  104. data/lib/generators/rails_onboarding/templates/add_analytics_to_rails_onboarding.rb +33 -0
  105. data/lib/generators/rails_onboarding/templates/add_milestone_tracking_to_users.rb +38 -0
  106. data/lib/generators/rails_onboarding/templates/add_onboarding_indexes.rb +93 -0
  107. data/lib/generators/rails_onboarding/templates/add_onboarding_to_users.rb +51 -0
  108. data/lib/generators/rails_onboarding/templates/add_robustness_fields_to_users.rb.tt +20 -0
  109. data/lib/generators/rails_onboarding/templates/create_rails_onboarding_flows.rb +18 -0
  110. data/lib/generators/rails_onboarding/templates/onboarding.css +18 -0
  111. data/lib/generators/rails_onboarding/templates/rails_onboarding.rb +75 -0
  112. data/lib/generators/rails_onboarding/update_generator.rb +73 -0
  113. data/lib/rails_onboarding/api_mode.rb +367 -0
  114. data/lib/rails_onboarding/backfill.rb +130 -0
  115. data/lib/rails_onboarding/background_jobs.rb +412 -0
  116. data/lib/rails_onboarding/caching.rb +180 -0
  117. data/lib/rails_onboarding/cdn_support.rb +202 -0
  118. data/lib/rails_onboarding/configuration/ab_testing.rb +24 -0
  119. data/lib/rails_onboarding/configuration/analytics.rb +29 -0
  120. data/lib/rails_onboarding/configuration/integrations.rb +31 -0
  121. data/lib/rails_onboarding/configuration/milestones.rb +92 -0
  122. data/lib/rails_onboarding/configuration/personalization.rb +25 -0
  123. data/lib/rails_onboarding/configuration/progressive_disclosure.rb +41 -0
  124. data/lib/rails_onboarding/configuration/rate_limiting.rb +18 -0
  125. data/lib/rails_onboarding/configuration/steps.rb +121 -0
  126. data/lib/rails_onboarding/configuration/templates.rb +85 -0
  127. data/lib/rails_onboarding/configuration/tooltips.rb +32 -0
  128. data/lib/rails_onboarding/configuration.rb +133 -0
  129. data/lib/rails_onboarding/configuration_errors.rb +19 -0
  130. data/lib/rails_onboarding/configuration_validator.rb +477 -0
  131. data/lib/rails_onboarding/controller_helpers.rb +153 -0
  132. data/lib/rails_onboarding/current.rb +13 -0
  133. data/lib/rails_onboarding/deprecation.rb +64 -0
  134. data/lib/rails_onboarding/devise_integration.rb +122 -0
  135. data/lib/rails_onboarding/engine.rb +245 -0
  136. data/lib/rails_onboarding/error_recovery.rb +173 -0
  137. data/lib/rails_onboarding/lazy_loading.rb +155 -0
  138. data/lib/rails_onboarding/multi_tenant.rb +306 -0
  139. data/lib/rails_onboarding/requirements_validator.rb +315 -0
  140. data/lib/rails_onboarding/responsive_helper.rb +218 -0
  141. data/lib/rails_onboarding/session_manager.rb +187 -0
  142. data/lib/rails_onboarding/skip_logic.rb +159 -0
  143. data/lib/rails_onboarding/turbo_compatibility.rb +229 -0
  144. data/lib/rails_onboarding/version.rb +3 -0
  145. data/lib/rails_onboarding.rb +44 -0
  146. data/lib/tasks/rails_onboarding_analytics.rake +136 -0
  147. data/lib/tasks/rails_onboarding_tasks.rake +162 -0
  148. metadata +303 -0
@@ -0,0 +1,1237 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Grace period between the pointer leaving the trigger and the tooltip being
4
+ // torn down. The tooltip sits 12px away from its trigger (see the `gap` in
5
+ // calculatePosition), so the pointer has to cross a dead zone to reach the
6
+ // dismiss button - anything much shorter than this and the tooltip vanishes
7
+ // mid-journey.
8
+ const HIDE_GRACE_PERIOD = 300
9
+
10
+ // How long a feature tooltip stays up on its own before self-dismissing. The
11
+ // countdown is paused while the pointer or keyboard focus is inside the
12
+ // tooltip, so it can never expire out from under someone reading it.
13
+ const AUTO_HIDE_DELAY = 10000
14
+
15
+ export default class extends Controller {
16
+ static targets = ["content", "trigger"]
17
+ static values = {
18
+ position: { type: String, default: "top" },
19
+ delay: { type: Number, default: 0 },
20
+ feature: String,
21
+ dismissUrl: { type: String, default: "" }, // rails_onboarding.dismiss_tooltip_path(tooltip_id: feature)
22
+ dismissible: { type: Boolean, default: true },
23
+ trigger: { type: String, default: "hover" }, // hover, click, focus, idle, scroll, contextual
24
+ idleTime: { type: Number, default: 3000 }, // ms before showing on idle
25
+ scrollThreshold: { type: Number, default: 0.5 }, // % of element visible before showing
26
+ contextual: { type: Boolean, default: false }, // context-aware behavior
27
+ priority: { type: Number, default: 5 }, // 1-10 priority for tooltip importance
28
+ maxDaily: { type: Number, default: 3 }, // max times to show per day
29
+ animation: { type: String, default: "fade" }, // fade, slide, bounce, scale, none
30
+ duration: { type: Number, default: 200 } // animation duration in ms
31
+ }
32
+
33
+ connect() {
34
+ this.tooltip = null
35
+ this.showTimeout = null
36
+ this.hideTimeout = null
37
+ this.idleTimeout = null
38
+ this.autoHideTimeout = null
39
+ this.tooltipEntered = false
40
+ this.userInteractionCount = 0
41
+ this.lastInteractionTime = Date.now()
42
+ this.setupEventListeners()
43
+ this.initializeContextualBehavior()
44
+ }
45
+
46
+ // Setup event listeners for showing/hiding tooltip
47
+ setupEventListeners() {
48
+ const target = this.hasTriggerTarget ? this.triggerTarget : this.element
49
+
50
+ // Setup based on trigger type
51
+ switch (this.triggerValue) {
52
+ case 'hover':
53
+ target.addEventListener('mouseenter', this.show.bind(this))
54
+ target.addEventListener('mouseleave', this.hide.bind(this))
55
+ target.addEventListener('focus', this.show.bind(this))
56
+ target.addEventListener('blur', this.hide.bind(this))
57
+ break
58
+
59
+ case 'click':
60
+ target.addEventListener('click', this.toggle.bind(this))
61
+ // Close on outside click
62
+ document.addEventListener('click', this.handleOutsideClick.bind(this))
63
+ break
64
+
65
+ case 'focus':
66
+ target.addEventListener('focus', this.show.bind(this))
67
+ target.addEventListener('blur', this.hide.bind(this))
68
+ break
69
+
70
+ case 'idle':
71
+ this.setupIdleDetection()
72
+ break
73
+
74
+ case 'scroll':
75
+ this.setupScrollDetection()
76
+ break
77
+
78
+ case 'contextual':
79
+ this.setupContextualTriggers()
80
+ break
81
+
82
+ case 'auto':
83
+ // Shown programmatically the instant it connects, with no user
84
+ // interaction - this is what the tooltip scheduler relies on when
85
+ // it appends a trigger:auto tooltip for a guided-tour step. Defer
86
+ // one macrotask so the freshly-appended element has settled before
87
+ // positionTooltip measures it; setTimeout (not requestAnimationFrame)
88
+ // so it still fires in a backgrounded tab. Hiding is driven by the
89
+ // scheduler via forceHide().
90
+ setTimeout(() => this.show(), 0)
91
+ break
92
+
93
+ default: // hover
94
+ target.addEventListener('mouseenter', this.show.bind(this))
95
+ target.addEventListener('mouseleave', this.hide.bind(this))
96
+ target.addEventListener('focus', this.show.bind(this))
97
+ target.addEventListener('blur', this.hide.bind(this))
98
+ }
99
+
100
+ // Handle keyboard events
101
+ document.addEventListener('keydown', this.handleKeyboard.bind(this))
102
+
103
+ // Track user interactions for contextual behavior
104
+ if (this.contextualValue) {
105
+ this.setupInteractionTracking()
106
+ }
107
+ }
108
+
109
+ // Initialize contextual behavior patterns
110
+ initializeContextualBehavior() {
111
+ if (!this.contextualValue) return
112
+
113
+ // Load user behavior patterns from localStorage
114
+ this.behaviorData = JSON.parse(localStorage.getItem('rails_onboarding_behavior') || '{}')
115
+ this.todayKey = new Date().toISOString().split('T')[0]
116
+
117
+ // Initialize today's data if needed
118
+ if (!this.behaviorData[this.todayKey]) {
119
+ this.behaviorData[this.todayKey] = {
120
+ tooltipsShown: {},
121
+ interactions: 0,
122
+ strugglingAreas: [],
123
+ timeOnPage: 0,
124
+ pageViews: 0
125
+ }
126
+ }
127
+
128
+ // Track page view
129
+ this.behaviorData[this.todayKey].pageViews++
130
+ this.saveBehaviorData()
131
+
132
+ // Start page time tracking
133
+ this.pageStartTime = Date.now()
134
+ }
135
+
136
+ // Setup idle detection for idle trigger type
137
+ setupIdleDetection() {
138
+ const target = this.hasTriggerTarget ? this.triggerTarget : this.element
139
+
140
+ // Show tooltip after idle period
141
+ const startIdleTimer = () => {
142
+ if (this.idleTimeout) clearTimeout(this.idleTimeout)
143
+ this.idleTimeout = setTimeout(() => {
144
+ if (this.shouldShow()) {
145
+ this.show()
146
+ }
147
+ }, this.idleTimeValue)
148
+ }
149
+
150
+ // Reset timer on any interaction
151
+ const resetIdleTimer = () => {
152
+ if (this.idleTimeout) {
153
+ clearTimeout(this.idleTimeout)
154
+ this.idleTimeout = null
155
+ }
156
+ }
157
+
158
+ // Track mouse/keyboard activity
159
+ ['mouseenter', 'mousemove', 'keydown', 'scroll'].forEach(event => {
160
+ target.addEventListener(event, () => {
161
+ resetIdleTimer()
162
+ startIdleTimer()
163
+ })
164
+ })
165
+
166
+ // Start initial timer
167
+ startIdleTimer()
168
+ }
169
+
170
+ // Setup scroll-based detection
171
+ setupScrollDetection() {
172
+ const target = this.hasTriggerTarget ? this.triggerTarget : this.element
173
+
174
+ const checkVisibility = () => {
175
+ const rect = target.getBoundingClientRect()
176
+ const visibleHeight = Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0)
177
+ const visibilityRatio = Math.max(0, visibleHeight) / rect.height
178
+
179
+ if (visibilityRatio >= this.scrollThresholdValue && this.shouldShow()) {
180
+ this.show()
181
+ // Remove scroll listener once shown
182
+ window.removeEventListener('scroll', checkVisibility)
183
+ }
184
+ }
185
+
186
+ window.addEventListener('scroll', checkVisibility)
187
+ // Check initial visibility
188
+ checkVisibility()
189
+ }
190
+
191
+ // Setup contextual triggers based on user behavior
192
+ setupContextualTriggers() {
193
+ const behaviorData = this.behaviorData?.[this.todayKey]
194
+ if (!behaviorData) return
195
+
196
+ // Show tooltip if user seems to be struggling
197
+ if (this.detectStruggling()) {
198
+ setTimeout(() => {
199
+ if (this.shouldShow()) this.show()
200
+ }, 2000) // Show after 2 seconds of struggling
201
+ }
202
+
203
+ // Show high-priority tooltips for new users
204
+ if (this.isNewUser() && this.priorityValue >= 8) {
205
+ setTimeout(() => {
206
+ if (this.shouldShow()) this.show()
207
+ }, 1000)
208
+ }
209
+
210
+ // Show tooltips based on time spent on page
211
+ if (behaviorData.timeOnPage > 30000 && this.priorityValue >= 6) { // 30+ seconds
212
+ setTimeout(() => {
213
+ if (this.shouldShow()) this.show()
214
+ }, 500)
215
+ }
216
+ }
217
+
218
+ // Setup interaction tracking for contextual behavior
219
+ setupInteractionTracking() {
220
+ const target = this.hasTriggerTarget ? this.triggerTarget : this.element
221
+
222
+ // Track various interactions
223
+ ['click', 'focus', 'mouseenter'].forEach(eventType => {
224
+ target.addEventListener(eventType, () => {
225
+ this.trackInteraction(eventType)
226
+ })
227
+ })
228
+
229
+ // Track page visibility
230
+ document.addEventListener('visibilitychange', () => {
231
+ if (document.hidden) {
232
+ this.updateTimeOnPage()
233
+ }
234
+ })
235
+
236
+ // Track before page unload
237
+ window.addEventListener('beforeunload', () => {
238
+ this.updateTimeOnPage()
239
+ })
240
+ }
241
+
242
+ // Handle outside clicks for click trigger
243
+ handleOutsideClick(event) {
244
+ if (this.tooltip && !this.tooltip.contains(event.target) &&
245
+ !this.element.contains(event.target)) {
246
+ this.hide()
247
+ }
248
+ }
249
+
250
+ // Detect if user is struggling with current element/feature
251
+ detectStruggling() {
252
+ const behaviorData = this.behaviorData?.[this.todayKey]
253
+ if (!behaviorData) return false
254
+
255
+ const feature = this.featureValue || this.element.id || 'unknown'
256
+
257
+ // Look for signs of struggling:
258
+ // 1. Multiple clicks without progress
259
+ // 2. Long hover times without action
260
+ // 3. Repeated visits to same area
261
+ // 4. Error patterns
262
+
263
+ const strugglingIndicators = behaviorData.strugglingAreas || []
264
+ return strugglingIndicators.includes(feature)
265
+ }
266
+
267
+ // Check if user is new (within first few days)
268
+ isNewUser() {
269
+ const allData = JSON.parse(localStorage.getItem('rails_onboarding_behavior') || '{}')
270
+ const dayCount = Object.keys(allData).length
271
+ return dayCount <= 3 // Consider new if less than 3 days of data
272
+ }
273
+
274
+ // Track user interactions
275
+ trackInteraction(type) {
276
+ this.lastInteractionTime = Date.now()
277
+ this.userInteractionCount++
278
+
279
+ if (this.behaviorData && this.behaviorData[this.todayKey]) {
280
+ this.behaviorData[this.todayKey].interactions++
281
+ this.saveBehaviorData()
282
+ }
283
+ }
284
+
285
+ // Update time spent on current page
286
+ updateTimeOnPage() {
287
+ if (this.pageStartTime && this.behaviorData && this.behaviorData[this.todayKey]) {
288
+ const timeSpent = Date.now() - this.pageStartTime
289
+ this.behaviorData[this.todayKey].timeOnPage += timeSpent
290
+ this.saveBehaviorData()
291
+ this.pageStartTime = Date.now() // Reset for next session
292
+ }
293
+ }
294
+
295
+ // Save behavior data to localStorage
296
+ saveBehaviorData() {
297
+ localStorage.setItem('rails_onboarding_behavior', JSON.stringify(this.behaviorData))
298
+ }
299
+
300
+ // Record that this tooltip was shown today
301
+ recordTooltipShow() {
302
+ if (!this.featureValue || !this.behaviorData || !this.behaviorData[this.todayKey]) return
303
+
304
+ const feature = this.featureValue
305
+ if (!this.behaviorData[this.todayKey].tooltipsShown) {
306
+ this.behaviorData[this.todayKey].tooltipsShown = {}
307
+ }
308
+
309
+ this.behaviorData[this.todayKey].tooltipsShown[feature] =
310
+ (this.behaviorData[this.todayKey].tooltipsShown[feature] || 0) + 1
311
+
312
+ this.saveBehaviorData()
313
+ }
314
+
315
+ // Show tooltip
316
+ show(event) {
317
+ if (event) event.preventDefault()
318
+
319
+ // Clear any existing timeouts
320
+ if (this.hideTimeout) {
321
+ clearTimeout(this.hideTimeout)
322
+ this.hideTimeout = null
323
+ }
324
+
325
+ // Set show timeout if delay is specified
326
+ if (this.delayValue > 0) {
327
+ this.showTimeout = setTimeout(() => {
328
+ this.createTooltip()
329
+ }, this.delayValue)
330
+ } else {
331
+ this.createTooltip()
332
+ }
333
+ }
334
+
335
+ // Hide tooltip
336
+ hide(event) {
337
+ if (event) event.preventDefault()
338
+
339
+ // Clear show timeout if it exists
340
+ if (this.showTimeout) {
341
+ clearTimeout(this.showTimeout)
342
+ this.showTimeout = null
343
+ }
344
+
345
+ // The user is inside the tooltip, so ignore trailing mouseleave/blur
346
+ // events from the trigger. Clicking the dismiss button blurs the
347
+ // trigger, and without this that blur would schedule a hide out from
348
+ // under the pointer that is currently resting on the button.
349
+ if (this.tooltipEntered) return
350
+
351
+ // Hide after a grace period so the pointer can cross the gap into the
352
+ // tooltip itself - once it lands there, cancelPendingHide() stops this.
353
+ if (this.hideTimeout) clearTimeout(this.hideTimeout)
354
+ this.hideTimeout = setTimeout(() => {
355
+ this.hideTimeout = null
356
+ this.removeTooltip()
357
+ }, HIDE_GRACE_PERIOD)
358
+ }
359
+
360
+ // Cancel a hide that hasn't fired yet. Called when the pointer or keyboard
361
+ // focus enters the tooltip, which is the whole reason the dismiss button is
362
+ // reachable: the tooltip lives on document.body rather than inside the
363
+ // trigger, so leaving the trigger always fires mouseleave/blur.
364
+ cancelPendingHide() {
365
+ if (this.hideTimeout) {
366
+ clearTimeout(this.hideTimeout)
367
+ this.hideTimeout = null
368
+ }
369
+ }
370
+
371
+ // Force show tooltip (for click events)
372
+ forceShow(event) {
373
+ if (event) event.preventDefault()
374
+ this.createTooltip()
375
+ }
376
+
377
+ // Force hide tooltip
378
+ forceHide(event) {
379
+ if (event) event.preventDefault()
380
+ this.removeTooltip()
381
+ }
382
+
383
+ // Create and display tooltip
384
+ createTooltip() {
385
+ // Remove existing tooltip
386
+ this.removeTooltip()
387
+
388
+ // Get content
389
+ const content = this.getTooltipContent()
390
+ if (!content) return
391
+
392
+ // Create tooltip element
393
+ this.tooltip = document.createElement('div')
394
+ this.tooltip.className = 'onboarding-tooltip'
395
+ this.tooltip.innerHTML = `
396
+ <div class="tooltip-arrow"></div>
397
+ <div class="tooltip-inner">
398
+ ${content}
399
+ ${this.dismissibleValue ? '<button type="button" class="tooltip-dismiss" aria-label="Dismiss">×</button>' : ''}
400
+ </div>
401
+ `
402
+
403
+ // Style the tooltip
404
+ this.styleTooltip()
405
+
406
+ // Add to DOM
407
+ document.body.appendChild(this.tooltip)
408
+
409
+ // Position tooltip
410
+ this.positionTooltip()
411
+
412
+ // Setup dismiss handler
413
+ if (this.dismissibleValue) {
414
+ const dismissButton = this.tooltip.querySelector('.tooltip-dismiss')
415
+ if (dismissButton) {
416
+ dismissButton.addEventListener('click', this.dismiss.bind(this))
417
+ }
418
+ }
419
+
420
+ // Keep the tooltip alive while the user is inside it
421
+ this.bindTooltipKeepAlive()
422
+
423
+ // Track tooltip show event
424
+ this.trackTooltipEvent('show')
425
+ this.recordTooltipShow()
426
+
427
+ // Auto-hide feature tooltips
428
+ this.startAutoHide()
429
+ }
430
+
431
+ // Make the tooltip itself a safe place for the pointer and for keyboard
432
+ // focus. Without this, a hover- or focus-triggered tooltip tears itself down
433
+ // the moment the user moves off the trigger towards the dismiss button, and
434
+ // tabbing to that button is impossible.
435
+ bindTooltipKeepAlive() {
436
+ if (!this.tooltip) return
437
+
438
+ const enter = () => {
439
+ this.tooltipEntered = true
440
+ this.cancelPendingHide()
441
+ this.stopAutoHide()
442
+ }
443
+
444
+ const leave = () => {
445
+ this.tooltipEntered = false
446
+ this.startAutoHide()
447
+ this.hide()
448
+ }
449
+
450
+ this.tooltip.addEventListener('mouseenter', enter)
451
+ this.tooltip.addEventListener('mouseleave', leave)
452
+ this.tooltip.addEventListener('focusin', enter)
453
+ this.tooltip.addEventListener('focusout', (event) => {
454
+ // Ignore focus moving between elements inside the tooltip
455
+ if (this.tooltip && this.tooltip.contains(event.relatedTarget)) return
456
+ leave()
457
+ })
458
+ }
459
+
460
+ // Start (or restart) the self-dismiss countdown for feature tooltips
461
+ startAutoHide() {
462
+ if (!this.featureValue) return
463
+
464
+ this.stopAutoHide()
465
+ this.autoHideTimeout = setTimeout(() => {
466
+ this.autoHideTimeout = null
467
+ this.removeTooltip()
468
+ }, AUTO_HIDE_DELAY)
469
+ }
470
+
471
+ // Cancel the self-dismiss countdown. Also called from removeTooltip() so a
472
+ // timer from a previous tooltip can't reach forward and kill a later one.
473
+ stopAutoHide() {
474
+ if (this.autoHideTimeout) {
475
+ clearTimeout(this.autoHideTimeout)
476
+ this.autoHideTimeout = null
477
+ }
478
+ }
479
+
480
+ // Remove tooltip from DOM with animation
481
+ //
482
+ // The element being torn down is captured up front and this.tooltip is
483
+ // released immediately, because the exit animation finishes a tick later:
484
+ // if a new tooltip has been created in the meantime (show -> hide -> show
485
+ // inside the animation duration, which createTooltip does every time it
486
+ // calls removeTooltip first), deferring to this.tooltip in the callback
487
+ // would rip the *new* tooltip out of the DOM.
488
+ removeTooltip() {
489
+ this.stopAutoHide()
490
+ this.tooltipEntered = false
491
+
492
+ const tooltip = this.tooltip
493
+ if (!tooltip) return
494
+
495
+ this.tooltip = null
496
+
497
+ // Removal is committed at this point - stop the fading tooltip from
498
+ // swallowing clicks or re-triggering its own keep-alive handlers.
499
+ tooltip.style.pointerEvents = 'none'
500
+
501
+ this.animateTooltipOut(tooltip, () => {
502
+ if (tooltip.parentNode) {
503
+ tooltip.parentNode.removeChild(tooltip)
504
+ }
505
+ })
506
+ }
507
+
508
+ // Get tooltip content
509
+ getTooltipContent() {
510
+ if (this.hasContentTarget) {
511
+ return this.contentTarget.innerHTML
512
+ } else if (this.element.dataset.tooltipText) {
513
+ return this.element.dataset.tooltipText
514
+ } else if (this.featureValue) {
515
+ return this.getFeatureTooltipContent()
516
+ }
517
+ return null
518
+ }
519
+
520
+ // Get feature-specific tooltip content
521
+ //
522
+ // There's no way for this static JS to read config.feature_tooltips
523
+ // (that lives server-side in Ruby), so there's nothing meaningful to
524
+ // fall back to here - the host app must supply the actual copy via a
525
+ // data-tooltip-target="content" element or a data-tooltip-text
526
+ // attribute (both checked before this method runs, in
527
+ // getTooltipContent() above).
528
+ getFeatureTooltipContent() {
529
+ console.warn(
530
+ `RailsOnboarding: tooltip for feature "${this.featureValue}" has no ` +
531
+ `content. Add a data-tooltip-target="content" element, or a ` +
532
+ `data-tooltip-text attribute, with the text configured in ` +
533
+ `config.feature_tooltips["${this.featureValue}"].`
534
+ )
535
+ return `<p>Learn more about this feature.</p>`
536
+ }
537
+
538
+ // Animate tooltip entrance
539
+ animateTooltipIn() {
540
+ if (!this.tooltip) return
541
+
542
+ // Set initial state based on animation type
543
+ this.setInitialAnimationState()
544
+
545
+ // Apply animation class for CSS transitions
546
+ this.tooltip.classList.add('tooltip-animating-in')
547
+ this.tooltip.style.animationDuration = `${this.durationValue}ms`
548
+
549
+ // Trigger animation
550
+ requestAnimationFrame(() => {
551
+ if (this.tooltip) {
552
+ this.setFinalAnimationState()
553
+
554
+ // Remove animation class after completion
555
+ setTimeout(() => {
556
+ if (this.tooltip) {
557
+ this.tooltip.classList.remove('tooltip-animating-in')
558
+ }
559
+ }, this.durationValue)
560
+ }
561
+ })
562
+ }
563
+
564
+ // Animate tooltip exit
565
+ //
566
+ // Takes the element explicitly rather than reading this.tooltip - by the
567
+ // time this runs, this.tooltip may already point at a replacement.
568
+ animateTooltipOut(tooltip, callback) {
569
+ if (!tooltip) {
570
+ if (callback) callback()
571
+ return
572
+ }
573
+
574
+ // Add exit animation class
575
+ tooltip.classList.add('tooltip-animating-out')
576
+ tooltip.style.animationDuration = `${this.durationValue}ms`
577
+
578
+ // Set exit state based on animation type
579
+ this.setExitAnimationState(tooltip)
580
+
581
+ // Remove after animation completes
582
+ setTimeout(() => {
583
+ if (callback) callback()
584
+ }, this.durationValue)
585
+ }
586
+
587
+ // Set initial animation state
588
+ setInitialAnimationState() {
589
+ if (!this.tooltip) return
590
+
591
+ const baseStyles = {
592
+ transition: `all ${this.durationValue}ms ease-out`,
593
+ pointerEvents: 'none'
594
+ }
595
+
596
+ switch (this.animationValue) {
597
+ case 'fade':
598
+ Object.assign(this.tooltip.style, {
599
+ ...baseStyles,
600
+ opacity: '0'
601
+ })
602
+ break
603
+
604
+ case 'slide':
605
+ const slideDirection = this.getSlideDirection()
606
+ Object.assign(this.tooltip.style, {
607
+ ...baseStyles,
608
+ opacity: '0',
609
+ transform: `translate${slideDirection.axis}(${slideDirection.distance}px)`
610
+ })
611
+ break
612
+
613
+ case 'bounce':
614
+ Object.assign(this.tooltip.style, {
615
+ ...baseStyles,
616
+ opacity: '0',
617
+ transform: 'scale(0.3)',
618
+ animationTimingFunction: 'cubic-bezier(0.68, -0.55, 0.265, 1.55)'
619
+ })
620
+ break
621
+
622
+ case 'scale':
623
+ Object.assign(this.tooltip.style, {
624
+ ...baseStyles,
625
+ opacity: '0',
626
+ transform: 'scale(0.8)',
627
+ transformOrigin: this.getTransformOrigin()
628
+ })
629
+ break
630
+
631
+ case 'none':
632
+ Object.assign(this.tooltip.style, {
633
+ opacity: '1',
634
+ pointerEvents: 'auto'
635
+ })
636
+ break
637
+
638
+ default: // fade
639
+ Object.assign(this.tooltip.style, {
640
+ ...baseStyles,
641
+ opacity: '0'
642
+ })
643
+ }
644
+ }
645
+
646
+ // Set final animation state
647
+ setFinalAnimationState() {
648
+ if (!this.tooltip) return
649
+
650
+ switch (this.animationValue) {
651
+ case 'fade':
652
+ this.tooltip.style.opacity = '1'
653
+ this.tooltip.style.pointerEvents = 'auto'
654
+ break
655
+
656
+ case 'slide':
657
+ this.tooltip.style.opacity = '1'
658
+ this.tooltip.style.transform = 'translate(0, 0)'
659
+ this.tooltip.style.pointerEvents = 'auto'
660
+ break
661
+
662
+ case 'bounce':
663
+ case 'scale':
664
+ this.tooltip.style.opacity = '1'
665
+ this.tooltip.style.transform = 'scale(1)'
666
+ this.tooltip.style.pointerEvents = 'auto'
667
+ break
668
+
669
+ case 'none':
670
+ // Already set in initial state
671
+ break
672
+
673
+ default: // fade
674
+ this.tooltip.style.opacity = '1'
675
+ this.tooltip.style.pointerEvents = 'auto'
676
+ }
677
+ }
678
+
679
+ // Set exit animation state
680
+ setExitAnimationState(tooltip = this.tooltip) {
681
+ if (!tooltip) return
682
+
683
+ switch (this.animationValue) {
684
+ case 'fade':
685
+ tooltip.style.opacity = '0'
686
+ break
687
+
688
+ case 'slide':
689
+ const slideDirection = this.getSlideDirection()
690
+ tooltip.style.opacity = '0'
691
+ tooltip.style.transform = `translate${slideDirection.axis}(${slideDirection.distance}px)`
692
+ break
693
+
694
+ case 'bounce':
695
+ tooltip.style.opacity = '0'
696
+ tooltip.style.transform = 'scale(0.3)'
697
+ break
698
+
699
+ case 'scale':
700
+ tooltip.style.opacity = '0'
701
+ tooltip.style.transform = 'scale(0.8)'
702
+ break
703
+
704
+ case 'none':
705
+ tooltip.style.opacity = '0'
706
+ break
707
+
708
+ default: // fade
709
+ tooltip.style.opacity = '0'
710
+ }
711
+ }
712
+
713
+ // Get slide direction based on tooltip position
714
+ getSlideDirection() {
715
+ const position = this.positionValue
716
+
717
+ switch (position) {
718
+ case 'top':
719
+ return { axis: 'Y', distance: -20 }
720
+ case 'bottom':
721
+ return { axis: 'Y', distance: 20 }
722
+ case 'left':
723
+ return { axis: 'X', distance: -20 }
724
+ case 'right':
725
+ return { axis: 'X', distance: 20 }
726
+ default:
727
+ return { axis: 'Y', distance: -20 }
728
+ }
729
+ }
730
+
731
+ // Get transform origin for scale animations
732
+ getTransformOrigin() {
733
+ const position = this.positionValue
734
+
735
+ switch (position) {
736
+ case 'top':
737
+ return 'center bottom'
738
+ case 'bottom':
739
+ return 'center top'
740
+ case 'left':
741
+ return 'right center'
742
+ case 'right':
743
+ return 'left center'
744
+ default:
745
+ return 'center bottom'
746
+ }
747
+ }
748
+
749
+ // Style the tooltip
750
+ // Appearance (colours, radius, shadow, padding) lives in tooltips.css under
751
+ // .onboarding-tooltip, so tooltips follow the --onboarding-* design tokens and
752
+ // the dark-mode block like the rest of the gem. Only the animation state is set
753
+ // here, because animateTooltipIn/Out drive opacity and transform imperatively;
754
+ // position comes from positionTooltip(), which computes top/left in pixels.
755
+ styleTooltip() {
756
+ if (!this.tooltip) return
757
+
758
+ this.tooltip.style.opacity = "0"
759
+ this.tooltip.style.transform = "scale(0.95) translateY(-5px)"
760
+ this.tooltip.style.transition = "opacity 0.15s ease, transform 0.15s ease"
761
+ }
762
+
763
+ // Position tooltip relative to trigger element with smart collision detection
764
+ positionTooltip() {
765
+ if (!this.tooltip) return
766
+
767
+ const target = this.hasTriggerTarget ? this.triggerTarget : this.element
768
+ const targetRect = target.getBoundingClientRect()
769
+ const tooltipRect = this.tooltip.getBoundingClientRect()
770
+ const arrow = this.tooltip.querySelector('.tooltip-arrow')
771
+
772
+ // Get viewport info
773
+ const viewport = {
774
+ width: window.innerWidth,
775
+ height: window.innerHeight,
776
+ scrollX: window.scrollX,
777
+ scrollY: window.scrollY
778
+ }
779
+
780
+ const margin = 16 // Minimum margin from viewport edges
781
+
782
+ // Find the best position using smart collision detection
783
+ const bestPosition = this.findBestPosition(targetRect, tooltipRect, viewport, margin)
784
+
785
+ let { position, top, left, arrowPosition } = bestPosition
786
+
787
+ // Apply position
788
+ this.tooltip.style.top = `${top}px`
789
+ this.tooltip.style.left = `${left}px`
790
+
791
+ // Style arrow based on final position
792
+ if (arrow) {
793
+ this.styleArrowForPosition(arrow, position, arrowPosition, targetRect, tooltipRect)
794
+ }
795
+
796
+ // Add position class for CSS styling. The class names are namespaced
797
+ // (onboarding-top, onboarding-left, ...), so strip by exact class rather
798
+ // than with a \b word boundary - \b treats "-" as a boundary, so the old
799
+ // /\b(top|bottom|left|right)\b/ would also match inside a namespaced name.
800
+ this.tooltip.classList.remove(
801
+ "onboarding-top", "onboarding-bottom", "onboarding-left", "onboarding-right"
802
+ )
803
+ this.tooltip.classList.add(`onboarding-${position}`)
804
+
805
+ // Animate in with enhanced animations
806
+ this.animateTooltipIn()
807
+ }
808
+
809
+ // Find the best position for tooltip using collision detection
810
+ findBestPosition(targetRect, tooltipRect, viewport, margin) {
811
+ const positions = [
812
+ this.positionValue, // Preferred position first
813
+ 'top', 'bottom', 'left', 'right'
814
+ ].filter((pos, index, arr) => arr.indexOf(pos) === index) // Remove duplicates
815
+
816
+ let bestPosition = null
817
+ let bestScore = -1
818
+
819
+ for (const position of positions) {
820
+ const result = this.calculatePosition(position, targetRect, tooltipRect, viewport, margin)
821
+ const score = this.scorePosition(result, viewport, margin)
822
+
823
+ if (score > bestScore) {
824
+ bestScore = score
825
+ bestPosition = result
826
+ bestPosition.position = position
827
+ }
828
+
829
+ // If we found a perfect position, use it
830
+ if (score >= 100) break
831
+ }
832
+
833
+ return bestPosition
834
+ }
835
+
836
+ // Calculate position coordinates for a given direction
837
+ calculatePosition(position, targetRect, tooltipRect, viewport, margin) {
838
+ const gap = 12 // Gap between target and tooltip
839
+ let top, left, arrowPosition = { x: '50%', y: '50%' }
840
+
841
+ switch (position) {
842
+ case 'top':
843
+ top = targetRect.top - tooltipRect.height - gap
844
+ left = targetRect.left + (targetRect.width / 2) - (tooltipRect.width / 2)
845
+ arrowPosition = { x: '50%', y: 'bottom' }
846
+ break
847
+
848
+ case 'bottom':
849
+ top = targetRect.bottom + gap
850
+ left = targetRect.left + (targetRect.width / 2) - (tooltipRect.width / 2)
851
+ arrowPosition = { x: '50%', y: 'top' }
852
+ break
853
+
854
+ case 'left':
855
+ top = targetRect.top + (targetRect.height / 2) - (tooltipRect.height / 2)
856
+ left = targetRect.left - tooltipRect.width - gap
857
+ arrowPosition = { x: 'right', y: '50%' }
858
+ break
859
+
860
+ case 'right':
861
+ top = targetRect.top + (targetRect.height / 2) - (tooltipRect.height / 2)
862
+ left = targetRect.right + gap
863
+ arrowPosition = { x: 'left', y: '50%' }
864
+ break
865
+
866
+ default:
867
+ top = targetRect.top - tooltipRect.height - gap
868
+ left = targetRect.left + (targetRect.width / 2) - (tooltipRect.width / 2)
869
+ arrowPosition = { x: '50%', y: 'bottom' }
870
+ }
871
+
872
+ // Adjust horizontal position to stay in viewport
873
+ if (left < margin) {
874
+ const adjustment = margin - left
875
+ left = margin
876
+ // Adjust arrow position for horizontal shifts
877
+ if (position === 'top' || position === 'bottom') {
878
+ const arrowLeft = Math.max(16, (targetRect.left + targetRect.width / 2) - left)
879
+ arrowPosition.x = `${Math.min(arrowLeft, tooltipRect.width - 16)}px`
880
+ }
881
+ } else if (left + tooltipRect.width > viewport.width - margin) {
882
+ const adjustment = (left + tooltipRect.width) - (viewport.width - margin)
883
+ left = viewport.width - tooltipRect.width - margin
884
+ // Adjust arrow position for horizontal shifts
885
+ if (position === 'top' || position === 'bottom') {
886
+ const arrowLeft = (targetRect.left + targetRect.width / 2) - left
887
+ arrowPosition.x = `${Math.min(Math.max(16, arrowLeft), tooltipRect.width - 16)}px`
888
+ }
889
+ }
890
+
891
+ // Adjust vertical position to stay in viewport
892
+ if (top < margin) {
893
+ const adjustment = margin - top
894
+ top = margin
895
+ // Adjust arrow position for vertical shifts
896
+ if (position === 'left' || position === 'right') {
897
+ const arrowTop = Math.max(16, (targetRect.top + targetRect.height / 2) - top)
898
+ arrowPosition.y = `${Math.min(arrowTop, tooltipRect.height - 16)}px`
899
+ }
900
+ } else if (top + tooltipRect.height > viewport.height - margin) {
901
+ const adjustment = (top + tooltipRect.height) - (viewport.height - margin)
902
+ top = viewport.height - tooltipRect.height - margin
903
+ // Adjust arrow position for vertical shifts
904
+ if (position === 'left' || position === 'right') {
905
+ const arrowTop = (targetRect.top + targetRect.height / 2) - top
906
+ arrowPosition.y = `${Math.min(Math.max(16, arrowTop), tooltipRect.height - 16)}px`
907
+ }
908
+ }
909
+
910
+ return { top, left, arrowPosition }
911
+ }
912
+
913
+ // Score a position based on how well it fits and avoids collisions
914
+ scorePosition(result, viewport, margin) {
915
+ const { top, left } = result
916
+ const tooltipRect = this.tooltip.getBoundingClientRect()
917
+
918
+ let score = 100 // Start with perfect score
919
+
920
+ // Penalize if tooltip goes outside viewport
921
+ if (left < margin) score -= Math.abs(left - margin)
922
+ if (top < margin) score -= Math.abs(top - margin)
923
+ if (left + tooltipRect.width > viewport.width - margin) {
924
+ score -= Math.abs((left + tooltipRect.width) - (viewport.width - margin))
925
+ }
926
+ if (top + tooltipRect.height > viewport.height - margin) {
927
+ score -= Math.abs((top + tooltipRect.height) - (viewport.height - margin))
928
+ }
929
+
930
+ // Check for collisions with other elements
931
+ const collisionPenalty = this.checkCollisions(top, left, tooltipRect.width, tooltipRect.height)
932
+ score -= collisionPenalty
933
+
934
+ return Math.max(0, score)
935
+ }
936
+
937
+ // Check for collisions with fixed/sticky elements
938
+ checkCollisions(top, left, width, height) {
939
+ const tooltipArea = { top, left, right: left + width, bottom: top + height }
940
+ const fixedElements = document.querySelectorAll('[style*="position: fixed"], [style*="position: sticky"], .navbar, .header, .sidebar')
941
+
942
+ let penalty = 0
943
+
944
+ fixedElements.forEach(element => {
945
+ if (element === this.tooltip) return // Skip self
946
+
947
+ const rect = element.getBoundingClientRect()
948
+ const elementArea = {
949
+ top: rect.top,
950
+ left: rect.left,
951
+ right: rect.right,
952
+ bottom: rect.bottom
953
+ }
954
+
955
+ // Check for overlap
956
+ if (this.areasOverlap(tooltipArea, elementArea)) {
957
+ const overlapArea = this.calculateOverlapArea(tooltipArea, elementArea)
958
+ penalty += overlapArea * 0.5 // Penalty proportional to overlap
959
+ }
960
+ })
961
+
962
+ return penalty
963
+ }
964
+
965
+ // Check if two rectangular areas overlap
966
+ areasOverlap(area1, area2) {
967
+ return !(area1.right < area2.left ||
968
+ area2.right < area1.left ||
969
+ area1.bottom < area2.top ||
970
+ area2.bottom < area1.top)
971
+ }
972
+
973
+ // Calculate the area of overlap between two rectangles
974
+ calculateOverlapArea(area1, area2) {
975
+ const overlapLeft = Math.max(area1.left, area2.left)
976
+ const overlapTop = Math.max(area1.top, area2.top)
977
+ const overlapRight = Math.min(area1.right, area2.right)
978
+ const overlapBottom = Math.min(area1.bottom, area2.bottom)
979
+
980
+ if (overlapLeft < overlapRight && overlapTop < overlapBottom) {
981
+ return (overlapRight - overlapLeft) * (overlapBottom - overlapTop)
982
+ }
983
+
984
+ return 0
985
+ }
986
+
987
+ // Style arrow based on final position and adjustments
988
+ styleArrowForPosition(arrow, position, arrowPosition, targetRect, tooltipRect) {
989
+ const arrowSize = 6
990
+
991
+ // Reset arrow styles
992
+ arrow.style.cssText = `
993
+ position: absolute;
994
+ width: 0;
995
+ height: 0;
996
+ `
997
+
998
+ switch (position) {
999
+ case 'top':
1000
+ arrow.style.bottom = `-${arrowSize}px`
1001
+ arrow.style.left = arrowPosition.x
1002
+ arrow.style.transform = arrowPosition.x === '50%' ? 'translateX(-50%)' : 'none'
1003
+ arrow.style.borderLeft = `${arrowSize}px solid transparent`
1004
+ arrow.style.borderRight = `${arrowSize}px solid transparent`
1005
+ arrow.style.borderTopWidth = `${arrowSize}px`
1006
+ arrow.style.borderTopStyle = 'solid'
1007
+ break
1008
+
1009
+ case 'bottom':
1010
+ arrow.style.top = `-${arrowSize}px`
1011
+ arrow.style.left = arrowPosition.x
1012
+ arrow.style.transform = arrowPosition.x === '50%' ? 'translateX(-50%)' : 'none'
1013
+ arrow.style.borderLeft = `${arrowSize}px solid transparent`
1014
+ arrow.style.borderRight = `${arrowSize}px solid transparent`
1015
+ arrow.style.borderBottomWidth = `${arrowSize}px`
1016
+ arrow.style.borderBottomStyle = 'solid'
1017
+ break
1018
+
1019
+ case 'left':
1020
+ arrow.style.right = `-${arrowSize}px`
1021
+ arrow.style.top = arrowPosition.y
1022
+ arrow.style.transform = arrowPosition.y === '50%' ? 'translateY(-50%)' : 'none'
1023
+ arrow.style.borderTop = `${arrowSize}px solid transparent`
1024
+ arrow.style.borderBottom = `${arrowSize}px solid transparent`
1025
+ arrow.style.borderLeftWidth = `${arrowSize}px`
1026
+ arrow.style.borderLeftStyle = 'solid'
1027
+ break
1028
+
1029
+ case 'right':
1030
+ arrow.style.left = `-${arrowSize}px`
1031
+ arrow.style.top = arrowPosition.y
1032
+ arrow.style.transform = arrowPosition.y === '50%' ? 'translateY(-50%)' : 'none'
1033
+ arrow.style.borderTop = `${arrowSize}px solid transparent`
1034
+ arrow.style.borderBottom = `${arrowSize}px solid transparent`
1035
+ arrow.style.borderRightWidth = `${arrowSize}px`
1036
+ arrow.style.borderRightStyle = 'solid'
1037
+ break
1038
+ }
1039
+ }
1040
+
1041
+ // Style arrow based on position
1042
+ styleArrow(arrow, side, position, transform) {
1043
+ const arrowSize = 6
1044
+
1045
+ arrow.style[side] = `-${arrowSize}px`
1046
+ arrow.style[position === '50%' ? (side === 'top' || side === 'bottom' ? 'left' : 'top') : side] = position
1047
+ arrow.style.transform = transform
1048
+
1049
+ // Create arrow using borders
1050
+ if (side === 'top' || side === 'bottom') {
1051
+ arrow.style.borderLeft = `${arrowSize}px solid transparent`
1052
+ arrow.style.borderRight = `${arrowSize}px solid transparent`
1053
+ arrow.style[side === 'top' ? 'borderBottomWidth' : 'borderTopWidth'] = `${arrowSize}px`
1054
+ arrow.style[side === 'top' ? 'borderBottomStyle' : 'borderTopStyle'] = 'solid'
1055
+ } else {
1056
+ arrow.style.borderTop = `${arrowSize}px solid transparent`
1057
+ arrow.style.borderBottom = `${arrowSize}px solid transparent`
1058
+ arrow.style[side === 'left' ? 'borderRightWidth' : 'borderLeftWidth'] = `${arrowSize}px`
1059
+ arrow.style[side === 'left' ? 'borderRightStyle' : 'borderLeftStyle'] = 'solid'
1060
+ }
1061
+ }
1062
+
1063
+ // Dismiss tooltip and mark as seen
1064
+ dismiss(event) {
1065
+ if (event) event.preventDefault()
1066
+
1067
+ this.removeTooltip()
1068
+ this.markTooltipSeen()
1069
+ this.trackTooltipEvent('dismiss')
1070
+ }
1071
+
1072
+ // Mark tooltip as seen (for feature tooltips)
1073
+ markTooltipSeen() {
1074
+ if (this.featureValue) {
1075
+ // The engine's mount path isn't knowable from static JS, so this
1076
+ // relies on the host app supplying the real route via
1077
+ // data-tooltip-dismiss-url-value (e.g.
1078
+ // rails_onboarding.dismiss_tooltip_path(tooltip_id: "...")) -
1079
+ // there's no reliable path to guess here.
1080
+ if (this.hasDismissUrlValue && this.dismissUrlValue) {
1081
+ fetch(this.dismissUrlValue, {
1082
+ method: 'POST',
1083
+ headers: {
1084
+ 'Content-Type': 'application/x-www-form-urlencoded',
1085
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content,
1086
+ 'Accept': 'application/json'
1087
+ },
1088
+ body: `tooltip_id=${encodeURIComponent(this.featureValue)}`
1089
+ }).catch(() => {})
1090
+ } else {
1091
+ console.warn(
1092
+ `RailsOnboarding: tooltip "${this.featureValue}" has no ` +
1093
+ `data-tooltip-dismiss-url-value, so it was not marked as shown ` +
1094
+ `server-side and may reappear on the next page load. Set it to ` +
1095
+ `rails_onboarding.dismiss_tooltip_path(tooltip_id: "${this.featureValue}").`
1096
+ )
1097
+ }
1098
+
1099
+ // Also store in localStorage as backup
1100
+ const seenTooltips = JSON.parse(localStorage.getItem('rails_onboarding_seen_tooltips') || '{}')
1101
+ seenTooltips[this.featureValue] = new Date().toISOString()
1102
+ localStorage.setItem('rails_onboarding_seen_tooltips', JSON.stringify(seenTooltips))
1103
+ }
1104
+ }
1105
+
1106
+ // Track tooltip events for analytics
1107
+ trackTooltipEvent(action) {
1108
+ if (typeof gtag !== 'undefined') {
1109
+ gtag('event', `tooltip_${action}`, {
1110
+ feature: this.featureValue || 'unknown',
1111
+ position: this.positionValue,
1112
+ timestamp: new Date().toISOString()
1113
+ })
1114
+ }
1115
+ }
1116
+
1117
+ // Handle keyboard events
1118
+ handleKeyboard(event) {
1119
+ if (event.key === 'Escape' && this.tooltip) {
1120
+ this.dismiss()
1121
+ }
1122
+ }
1123
+
1124
+ // Check if tooltip should be shown (enhanced with contextual logic)
1125
+ shouldShow() {
1126
+ // Always show non-feature tooltips
1127
+ if (!this.featureValue) return true
1128
+
1129
+ // Check if already seen and dismissed
1130
+ const seenTooltips = JSON.parse(localStorage.getItem('rails_onboarding_seen_tooltips') || '{}')
1131
+ if (seenTooltips[this.featureValue]) {
1132
+ return false
1133
+ }
1134
+
1135
+ // Check daily limit
1136
+ if (this.maxDailyValue > 0) {
1137
+ const behaviorData = this.behaviorData?.[this.todayKey]
1138
+ if (behaviorData && behaviorData.tooltipsShown) {
1139
+ const todayCount = behaviorData.tooltipsShown[this.featureValue] || 0
1140
+ if (todayCount >= this.maxDailyValue) {
1141
+ return false
1142
+ }
1143
+ }
1144
+ }
1145
+
1146
+ // Apply contextual logic for better timing
1147
+ if (this.contextualValue) {
1148
+ return this.shouldShowContextually()
1149
+ }
1150
+
1151
+ return true
1152
+ }
1153
+
1154
+ // Determine if tooltip should show based on contextual factors
1155
+ shouldShowContextually() {
1156
+ const behaviorData = this.behaviorData?.[this.todayKey]
1157
+ if (!behaviorData) return true
1158
+
1159
+ // Don't overwhelm users - limit concurrent tooltips
1160
+ const activeTooltips = document.querySelectorAll('.onboarding-tooltip')
1161
+ if (activeTooltips.length >= 2) return false
1162
+
1163
+ // Respect user interaction patterns
1164
+ const timeSinceLastInteraction = Date.now() - this.lastInteractionTime
1165
+
1166
+ // If user is actively interacting, wait for a pause
1167
+ if (timeSinceLastInteraction < 2000 && this.priorityValue < 9) {
1168
+ return false
1169
+ }
1170
+
1171
+ // If user seems busy (many recent interactions), only show high priority
1172
+ if (behaviorData.interactions > 20 && this.priorityValue < 7) {
1173
+ return false
1174
+ }
1175
+
1176
+ // If it's user's first day, be more helpful
1177
+ if (this.isNewUser()) {
1178
+ return this.priorityValue >= 5
1179
+ }
1180
+
1181
+ // For returning users, be more selective
1182
+ return this.priorityValue >= 6
1183
+ }
1184
+
1185
+ // Show tooltip if it should be shown
1186
+ conditionalShow(event) {
1187
+ if (this.shouldShow()) {
1188
+ this.show(event)
1189
+ }
1190
+ }
1191
+
1192
+ // Toggle tooltip visibility
1193
+ toggle(event) {
1194
+ if (this.tooltip) {
1195
+ this.hide(event)
1196
+ } else {
1197
+ this.show(event)
1198
+ }
1199
+ }
1200
+
1201
+ // Update tooltip content
1202
+ updateContent(newContent) {
1203
+ if (this.tooltip) {
1204
+ const inner = this.tooltip.querySelector('.tooltip-inner')
1205
+ if (inner) {
1206
+ inner.innerHTML = newContent + (this.dismissibleValue ? '<button type="button" class="tooltip-dismiss" aria-label="Dismiss">×</button>' : '')
1207
+
1208
+ // Re-setup dismiss handler
1209
+ if (this.dismissibleValue) {
1210
+ const dismissButton = this.tooltip.querySelector('.tooltip-dismiss')
1211
+ if (dismissButton) {
1212
+ dismissButton.addEventListener('click', this.dismiss.bind(this))
1213
+ }
1214
+ }
1215
+
1216
+ // Re-position tooltip
1217
+ this.positionTooltip()
1218
+ }
1219
+ }
1220
+ }
1221
+
1222
+ // Cleanup on disconnect
1223
+ disconnect() {
1224
+ if (this.showTimeout) {
1225
+ clearTimeout(this.showTimeout)
1226
+ }
1227
+ if (this.hideTimeout) {
1228
+ clearTimeout(this.hideTimeout)
1229
+ }
1230
+ if (this.idleTimeout) {
1231
+ clearTimeout(this.idleTimeout)
1232
+ }
1233
+
1234
+ this.removeTooltip()
1235
+ document.removeEventListener('keydown', this.handleKeyboard)
1236
+ }
1237
+ }