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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +1351 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/rails_onboarding/admin/chart_controller.js +25 -0
- data/app/assets/javascripts/rails_onboarding/admin/filter_controller.js +49 -0
- data/app/assets/javascripts/rails_onboarding/admin/flash_controller.js +21 -0
- data/app/assets/javascripts/rails_onboarding/admin/flow_editor_controller.js +86 -0
- data/app/assets/javascripts/rails_onboarding/application.js +231 -0
- data/app/assets/javascripts/rails_onboarding/milestone_celebration_controller.js +177 -0
- data/app/assets/javascripts/rails_onboarding/milestone_dashboard_controller.js +132 -0
- data/app/assets/javascripts/rails_onboarding/milestone_detail_controller.js +34 -0
- data/app/assets/javascripts/rails_onboarding/navigation_controller.js +277 -0
- data/app/assets/javascripts/rails_onboarding/onboarding_controller.js +326 -0
- data/app/assets/javascripts/rails_onboarding/progress_controller.js +203 -0
- data/app/assets/javascripts/rails_onboarding/progressive_disclosure_controller.js +218 -0
- data/app/assets/javascripts/rails_onboarding/tooltip_controller.js +1237 -0
- data/app/assets/javascripts/rails_onboarding/tooltip_scheduler_controller.js +511 -0
- data/app/assets/javascripts/rails_onboarding/tour_controller.js +763 -0
- data/app/assets/stylesheets/rails_onboarding/accessibility.css +536 -0
- data/app/assets/stylesheets/rails_onboarding/admin.css +816 -0
- data/app/assets/stylesheets/rails_onboarding/application.css +1145 -0
- data/app/assets/stylesheets/rails_onboarding/flash_messages.css +237 -0
- data/app/assets/stylesheets/rails_onboarding/milestones.css +545 -0
- data/app/assets/stylesheets/rails_onboarding/mobile.css +856 -0
- data/app/assets/stylesheets/rails_onboarding/progressive_disclosure.css +299 -0
- data/app/assets/stylesheets/rails_onboarding/tooltips.css +468 -0
- data/app/assets/stylesheets/rails_onboarding/tour.css +495 -0
- data/app/assets/stylesheets/rails_onboarding/utilities.css +393 -0
- data/app/controllers/concerns/rails_onboarding/admin_authorization.rb +98 -0
- data/app/controllers/concerns/rails_onboarding/rate_limitable.rb +104 -0
- data/app/controllers/rails_onboarding/ab_tests_controller.rb +257 -0
- data/app/controllers/rails_onboarding/admin/ab_tests_controller.rb +121 -0
- data/app/controllers/rails_onboarding/admin/base_controller.rb +68 -0
- data/app/controllers/rails_onboarding/admin/dashboard_controller.rb +189 -0
- data/app/controllers/rails_onboarding/admin/flows_controller.rb +175 -0
- data/app/controllers/rails_onboarding/admin/users_controller.rb +366 -0
- data/app/controllers/rails_onboarding/application_controller.rb +9 -0
- data/app/controllers/rails_onboarding/milestones_controller.rb +126 -0
- data/app/controllers/rails_onboarding/onboarding_controller.rb +409 -0
- data/app/controllers/rails_onboarding/progressive_features_controller.rb +134 -0
- data/app/controllers/rails_onboarding/templates_controller.rb +162 -0
- data/app/controllers/rails_onboarding/test_sessions_controller.rb +16 -0
- data/app/controllers/rails_onboarding/tooltips_controller.rb +92 -0
- data/app/helpers/rails_onboarding/application_helper.rb +4 -0
- data/app/helpers/rails_onboarding/i18n_helper.rb +137 -0
- data/app/helpers/rails_onboarding/personalization_helper.rb +184 -0
- data/app/helpers/rails_onboarding/templates_helper.rb +253 -0
- data/app/jobs/rails_onboarding/application_job.rb +4 -0
- data/app/mailers/rails_onboarding/application_mailer.rb +6 -0
- data/app/mailers/rails_onboarding/onboarding_mailer.rb +72 -0
- data/app/models/concerns/rails_onboarding/ab_testable.rb +149 -0
- data/app/models/concerns/rails_onboarding/onboardable.rb +649 -0
- data/app/models/concerns/rails_onboarding/personalizable.rb +211 -0
- data/app/models/concerns/rails_onboarding/progressive_disclosure.rb +276 -0
- data/app/models/rails_onboarding/analytics.rb +255 -0
- data/app/models/rails_onboarding/analytics_event.rb +168 -0
- data/app/models/rails_onboarding/application_record.rb +5 -0
- data/app/models/rails_onboarding/flow.rb +41 -0
- data/app/services/rails_onboarding/milestone_service.rb +105 -0
- data/app/views/layouts/rails_onboarding/admin.html.erb +106 -0
- data/app/views/layouts/rails_onboarding/application.html.erb +65 -0
- data/app/views/rails_onboarding/ab_tests/index.html.erb +209 -0
- data/app/views/rails_onboarding/ab_tests/results.html.erb +253 -0
- data/app/views/rails_onboarding/admin/ab_tests/_ab_test_card.html.erb +24 -0
- data/app/views/rails_onboarding/admin/ab_tests/index.html.erb +57 -0
- data/app/views/rails_onboarding/admin/ab_tests/show.html.erb +41 -0
- data/app/views/rails_onboarding/admin/dashboard/index.html.erb +224 -0
- data/app/views/rails_onboarding/admin/flows/_form.html.erb +147 -0
- data/app/views/rails_onboarding/admin/flows/edit.html.erb +7 -0
- data/app/views/rails_onboarding/admin/flows/index.html.erb +125 -0
- data/app/views/rails_onboarding/admin/flows/new.html.erb +7 -0
- data/app/views/rails_onboarding/admin/flows/preview.html.erb +40 -0
- data/app/views/rails_onboarding/admin/flows/show.html.erb +114 -0
- data/app/views/rails_onboarding/admin/users/index.html.erb +180 -0
- data/app/views/rails_onboarding/admin/users/show.html.erb +187 -0
- data/app/views/rails_onboarding/milestones/index.html.erb +64 -0
- data/app/views/rails_onboarding/milestones/show.html.erb +57 -0
- data/app/views/rails_onboarding/onboarding/_custom_footer.html.erb +2 -0
- data/app/views/rails_onboarding/onboarding/_progress_indicator.html.erb +56 -0
- data/app/views/rails_onboarding/onboarding/_step_navigation.html.erb +44 -0
- data/app/views/rails_onboarding/onboarding/step.html.erb +38 -0
- data/app/views/rails_onboarding/onboarding/welcome.html.erb +37 -0
- data/app/views/rails_onboarding/onboarding_mailer/completion_email.html.erb +109 -0
- data/app/views/rails_onboarding/onboarding_mailer/completion_email.text.erb +29 -0
- data/app/views/rails_onboarding/onboarding_mailer/reminder_email.html.erb +99 -0
- data/app/views/rails_onboarding/onboarding_mailer/reminder_email.text.erb +20 -0
- data/app/views/rails_onboarding/onboarding_mailer/step_completed_email.html.erb +105 -0
- data/app/views/rails_onboarding/onboarding_mailer/step_completed_email.text.erb +19 -0
- data/app/views/rails_onboarding/onboarding_mailer/welcome_email.html.erb +74 -0
- data/app/views/rails_onboarding/onboarding_mailer/welcome_email.text.erb +17 -0
- data/app/views/rails_onboarding/shared/_flash.html.erb +25 -0
- data/app/views/rails_onboarding/shared/_milestone_badge.html.erb +22 -0
- data/app/views/rails_onboarding/shared/_milestone_celebration.html.erb +44 -0
- data/app/views/rails_onboarding/shared/_onboarding_banner.html.erb +48 -0
- data/app/views/rails_onboarding/templates/index.html.erb +255 -0
- data/config/importmap.rb +33 -0
- data/config/locales/en.yml +62 -0
- data/config/locales/es.yml +62 -0
- data/config/locales/fr.yml +62 -0
- data/config/routes.rb +79 -0
- data/lib/generators/rails_onboarding/install_generator.rb +124 -0
- data/lib/generators/rails_onboarding/templates/README +120 -0
- data/lib/generators/rails_onboarding/templates/add_analytics_to_rails_onboarding.rb +33 -0
- data/lib/generators/rails_onboarding/templates/add_milestone_tracking_to_users.rb +38 -0
- data/lib/generators/rails_onboarding/templates/add_onboarding_indexes.rb +93 -0
- data/lib/generators/rails_onboarding/templates/add_onboarding_to_users.rb +51 -0
- data/lib/generators/rails_onboarding/templates/add_robustness_fields_to_users.rb.tt +20 -0
- data/lib/generators/rails_onboarding/templates/create_rails_onboarding_flows.rb +18 -0
- data/lib/generators/rails_onboarding/templates/onboarding.css +18 -0
- data/lib/generators/rails_onboarding/templates/rails_onboarding.rb +75 -0
- data/lib/generators/rails_onboarding/update_generator.rb +73 -0
- data/lib/rails_onboarding/api_mode.rb +367 -0
- data/lib/rails_onboarding/backfill.rb +130 -0
- data/lib/rails_onboarding/background_jobs.rb +412 -0
- data/lib/rails_onboarding/caching.rb +180 -0
- data/lib/rails_onboarding/cdn_support.rb +202 -0
- data/lib/rails_onboarding/configuration/ab_testing.rb +24 -0
- data/lib/rails_onboarding/configuration/analytics.rb +29 -0
- data/lib/rails_onboarding/configuration/integrations.rb +31 -0
- data/lib/rails_onboarding/configuration/milestones.rb +92 -0
- data/lib/rails_onboarding/configuration/personalization.rb +25 -0
- data/lib/rails_onboarding/configuration/progressive_disclosure.rb +41 -0
- data/lib/rails_onboarding/configuration/rate_limiting.rb +18 -0
- data/lib/rails_onboarding/configuration/steps.rb +121 -0
- data/lib/rails_onboarding/configuration/templates.rb +85 -0
- data/lib/rails_onboarding/configuration/tooltips.rb +32 -0
- data/lib/rails_onboarding/configuration.rb +133 -0
- data/lib/rails_onboarding/configuration_errors.rb +19 -0
- data/lib/rails_onboarding/configuration_validator.rb +477 -0
- data/lib/rails_onboarding/controller_helpers.rb +153 -0
- data/lib/rails_onboarding/current.rb +13 -0
- data/lib/rails_onboarding/deprecation.rb +64 -0
- data/lib/rails_onboarding/devise_integration.rb +122 -0
- data/lib/rails_onboarding/engine.rb +245 -0
- data/lib/rails_onboarding/error_recovery.rb +173 -0
- data/lib/rails_onboarding/lazy_loading.rb +155 -0
- data/lib/rails_onboarding/multi_tenant.rb +306 -0
- data/lib/rails_onboarding/requirements_validator.rb +315 -0
- data/lib/rails_onboarding/responsive_helper.rb +218 -0
- data/lib/rails_onboarding/session_manager.rb +187 -0
- data/lib/rails_onboarding/skip_logic.rb +159 -0
- data/lib/rails_onboarding/turbo_compatibility.rb +229 -0
- data/lib/rails_onboarding/version.rb +3 -0
- data/lib/rails_onboarding.rb +44 -0
- data/lib/tasks/rails_onboarding_analytics.rake +136 -0
- data/lib/tasks/rails_onboarding_tasks.rake +162 -0
- 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
|
+
}
|