1import { Controller } from "@hotwired/stimulus"; 2import { autoUpdate, computePosition, offset, shift } from "@floating-ui/dom"; 3import "number-flow"; 4import { continuous } from "number-flow"; 5 6export default class extends Controller { 7 static targets = [ 8 "panel", 9 "trigger", 10 "chevron", 11 "ring", 12 "progress", 13 "summary", 14 "taskList", 15 "liveRegion", 16 "modeButton", 17 "surface", 18 "demoButton", 19 "ready", 20 "progressVisual", 21 "completionHalo", 22 "remaining", 23 "title", 24 "panelTitle", 25 "panelDescription", 26 ]; 27 28 static values = { 29 data: String, 30 open: { type: Boolean, default: false }, 31 mode: { type: String, default: "overlay-below" }, 32 compact: { type: Boolean, default: false }, 33 }; 34 35 connect() { 36 // Cache panel-owned targets before overlay modes portal the panel outside 37 // this Stimulus controller's DOM scope. 38 this.panelElement = this.panelTarget; 39 this.taskListElement = this.taskListTarget; 40 this.remainingElement = this.hasRemainingTarget ? this.remainingTarget : null; 41 this.panelTitleElement = this.hasPanelTitleTarget ? this.panelTitleTarget : null; 42 this.panelDescriptionElement = this.hasPanelDescriptionTarget ? this.panelDescriptionTarget : null; 43 this.demoButtonElement = this.hasDemoButtonTarget ? this.demoButtonTarget : null; 44 this.surfaceElement = this.hasSurfaceTarget ? this.surfaceTarget : this.element; 45 this.triggerElement = this.hasTriggerTarget ? this.triggerTarget : null; 46 this.chevronElement = this.hasChevronTarget ? this.chevronTarget : null; 47 this.ringElement = this.hasRingTarget ? this.ringTarget : null; 48 this.progressElement = this.hasProgressTarget ? this.progressTarget : null; 49 this.summaryElement = this.hasSummaryTarget ? this.summaryTarget : null; 50 this.liveRegionElement = this.hasLiveRegionTarget ? this.liveRegionTarget : null; 51 this.completionHaloElement = this.hasCompletionHaloTarget ? this.completionHaloTarget : null; 52 this.progressVisualElement = this.hasProgressVisualTarget ? this.progressVisualTarget : null; 53 this.originalPanelParent = this.panelElement.parentNode; 54 this.originalPanelNextSibling = this.panelElement.nextSibling; 55 this.isPanelPortaled = false; 56 this.overlayPositionCleanup = null; 57 this.panelOpenSequence = 0; 58 this.panelClickHandler = this.handlePortaledPanelClick.bind(this); 59 this.panelElement.addEventListener("click", this.panelClickHandler); 60 61 this.reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); 62 this.config = this.parseData(); 63 this.tasks = this.cloneTasks(this.config.tasks || []); 64 this.bindCompletionEvents(); 65 this.bindUpdateEvent(); 66 this.initialTasks = this.cloneTasks(this.tasks); 67 this.timeoutIds = []; 68 this.batchRunning = false; 69 this.completionAnnounced = false; 70 this.dismissing = false; 71 this.progressInitialized = false; 72 this.pendingInitialPercentage = 0; 73 this.ringAnimationFrame = null; 74 this.panelAnimation = null; 75 this.completionAnimations = []; 76 this.successAnimations = []; 77 this.successParticles = []; 78 this.configureProgressFlow(); 79 this.currentPercentage = 0; 80 this.progressElement?.update(0); 81 this.connected = true; 82 this.render(); 83 this.applyMode(); 84 this.paintOpenState({ animate: false }); 85 } 86 87 disconnect() { 88 this.connected = false; 89 this.clearPendingTimeouts(); 90 window.cancelAnimationFrame(this.ringAnimationFrame); 91 this.stopAnimations(); 92 this.unbindCompletionEvents(); 93 this.unbindUpdateEvent(); 94 this.stopOverlayPositioning(); 95 this.panelElement?.removeEventListener("click", this.panelClickHandler); 96 this.restorePanelMount(); 97 } 98 99 dataValueChanged() { 100 if (!this.connected) return; 101 102 this.config = this.parseData(); 103 this.tasks = this.cloneTasks(this.config.tasks || []); 104 this.bindCompletionEvents(); 105 this.bindUpdateEvent(); 106 this.initialTasks = this.cloneTasks(this.tasks); 107 this.batchRunning = false; 108 this.completionAnnounced = false; 109 this.dismissing = false; 110 this.render(); 111 } 112 113 toggle() { 114 this.openValue = !this.openValue; 115 this.paintOpenState(); 116 } 117 118 close(event) { 119 if (!this.openValue || !this.isOverlayMode()) return; 120 if (this.surfaceElement.contains(event.target) || this.panelElement.contains(event.target)) return; 121 122 this.openValue = false;
123 this.paintOpenState(); 124 } 125 126 escape() { 127 if (!this.openValue) return; 128 129 this.openValue = false; 130 this.paintOpenState(); 131 } 132 133 changeMode(event) { 134 const nextMode = event.currentTarget.dataset.mode; 135 if (!nextMode || nextMode === this.modeValue) return; 136 137 this.modeValue = nextMode; 138 this.applyMode(); 139 this.paintOpenState({ animate: false }); 140 } 141 142 handlePortaledPanelClick(event) { 143 if (!this.isPanelPortaled || !(event.target instanceof Element)) return; 144 145 const actionElement = event.target.closest("[data-action]"); 146 if (!actionElement || !this.panelElement.contains(actionElement)) return; 147 148 const actions = (actionElement.dataset.action || "").split(/\s+/); 149 const delegatedActions = { 150 completeAll: () => this.completeAll(), 151 complete: () => this.complete({ currentTarget: actionElement }), 152 focusTask: () => this.focusTask({ currentTarget: actionElement }), 153 dispatchTaskAction: () => this.dispatchTaskAction({ currentTarget: actionElement }), 154 toggleSkip: () => this.toggleSkip({ currentTarget: actionElement }), 155 }; 156 157 for (const [method, callback] of Object.entries(delegatedActions)) { 158 if (!actions.some((action) => action.endsWith(`->onboarding-checklist#${method}`))) continue; 159 160 event.preventDefault(); 161 callback(); 162 break; 163 } 164 } 165 166 dispatchTaskAction(event) { 167 const task = this.taskFor(event.currentTarget.dataset.taskId); 168 const eventName = task?.actionEvent || task?.action_event; 169 if (typeof eventName !== "string" || eventName.trim() === "") return; 170 171 this.element.dispatchEvent( 172 new CustomEvent(eventName, { 173 bubbles: true, 174 detail: { 175 checklistId: this.checklistId(), 176 checklist_id: this.checklistId(), 177 task: { ...task }, 178 taskId: task.id, 179 task_id: task.id, 180 }, 181 }), 182 ); 183 184 if (task.closeOnAction || task.close_on_action) { 185 this.openValue = false; 186 this.paintOpenState(); 187 } 188 } 189 190 complete(event) { 191 const task = this.taskFor(event.currentTarget.dataset.taskId); 192 if (this.batchRunning || !task || ["completed", "loading", "skipped"].includes(task.status)) return; 193 194 task.status = "loading"; 195 this.render(); 196 this.announce(`${task.title} is in progress`); 197 198 this.schedule(() => { 199 task.status = "completed"; 200 this.render(); 201 this.announce(`${task.title} completed`); 202 this.celebrate(task.id); 203 }, 1100); 204 } 205 206 completeAll() { 207 const incompleteTasks = this.tasks.filter(({ status }) => !["completed", "skipped"].includes(status)); 208 209 if (incompleteTasks.length === 0) { 210 this.clearPendingTimeouts(); 211 this.stopSuccessAnimations(); 212 this.batchRunning = false; 213 this.tasks = this.cloneTasks(this.initialTasks); 214 this.render(); 215 this.announce("Checklist progress reset"); 216 return; 217 } 218 219 if (this.batchRunning) return; 220 221 this.batchRunning = true; 222 this.render(); 223 224 incompleteTasks.forEach((task, index) => { 225 const startDelay = index * 620; 226 227 this.schedule(() => { 228 task.status = "loading"; 229 this.render(); 230 this.announce(`${task.title} is in progress`); 231 }, startDelay); 232 233 this.schedule(() => { 234 task.status = "completed"; 235 this.render(); 236 this.celebrate(task.id); 237 238 if (index === incompleteTasks.length - 1) { 239 this.batchRunning = false; 240 this.render(); 241 this.announce("Onboarding complete â your workspace is ready"); 242 } 243 }, startDelay + 520); 244 }); 245 } 246 247 schedule(callback, delay) { 248 const timeoutId = window.setTimeout(() => { 249 this.timeoutIds = this.timeoutIds.filter((id) => id !== timeoutId); 250 callback(); 251 }, delay); 252 253 this.timeoutIds.push(timeoutId); 254 } 255 256 animateElement(element, keyframes, options = {}) { 257 const animation = element.animate(keyframes, { 258 fill: "forwards", 259 ...options, 260 }); 261 262 return { 263 finished: animation.finished.catch(() => {}), 264 stop: () => animation.cancel(), 265 }; 266 } 267
268 setSurfaceTransitionDuration(element, { opening, overlay = this.isOverlayMode() }) { 269 const durationClass = overlay 270 ? opening 271 ? "duration-300" 272 : "duration-200" 273 : opening 274 ? "duration-[450ms]" 275 : "duration-300"; 276 277 element.classList.remove("duration-200", "duration-300", "duration-[450ms]"); 278 element.classList.add(durationClass); 279 } 280 281 transitionInlinePanel({ opening, wasHidden = false }) { 282 const panel = this.panelElement; 283 const duration = opening ? 450 : 300; 284 const startHeight = opening 285 ? wasHidden 286 ? 0 287 : panel.getBoundingClientRect().height 288 : panel.getBoundingClientRect().height; 289 const endHeight = opening ? panel.scrollHeight : 0; 290 let animationFrame = null; 291 let timeoutId = null; 292 let resolveFinished; 293 294 const finished = new Promise((resolve) => { 295 resolveFinished = resolve; 296 }); 297 298 const finish = () => { 299 panel.removeEventListener("transitionend", handleTransitionEnd); 300 window.clearTimeout(timeoutId); 301 resolveFinished(); 302 }; 303 304 const handleTransitionEnd = (event) => { 305 if (event.target === panel && event.propertyName === "height") finish(); 306 }; 307 308 this.setSurfaceTransitionDuration(panel, { opening, overlay: false }); 309 panel.style.height = `${startHeight}px`; 310 panel.style.opacity = opening && wasHidden ? "0" : "1"; 311 panel.style.transform = ""; 312 panel.getBoundingClientRect(); 313 314 panel.addEventListener("transitionend", handleTransitionEnd); 315 animationFrame = window.requestAnimationFrame(() => { 316 panel.style.height = `${endHeight}px`; 317 panel.style.opacity = opening ? "1" : "0"; 318 }); 319 timeoutId = window.setTimeout(finish, duration + 80); 320 321 return { 322 finished, 323 stop: () => { 324 window.cancelAnimationFrame(animationFrame); 325 const styles = window.getComputedStyle(panel); 326 panel.style.height = styles.height; 327 panel.style.opacity = styles.opacity; 328 finish(); 329 }, 330 }; 331 } 332 333 transitionOverlayPanel({ opening, wasHidden = false }) { 334 const panel = this.panelElement; 335 const duration = opening ? 300 : 200; 336 const direction = this.isOverlayBelow() ? -1 : 1; 337 const animationFrames = []; 338 let timeoutId = null; 339 let resolveFinished; 340 let finishedTransition = false; 341 342 const finished = new Promise((resolve) => { 343 resolveFinished = resolve; 344 }); 345 346 const finish = () => { 347 if (finishedTransition) return; 348 finishedTransition = true; 349 panel.removeEventListener("transitionend", handleTransitionEnd); 350 window.clearTimeout(timeoutId); 351 resolveFinished(); 352 }; 353 354 const handleTransitionEnd = (event) => { 355 if (event.target === panel && event.propertyName === "transform") finish(); 356 }; 357 358 this.setSurfaceTransitionDuration(panel, { opening, overlay: true }); 359 panel.style.height = "auto"; 360 panel.style.opacity = opening && wasHidden ? "0" : "1"; 361 panel.style.transform = opening && wasHidden ? `translateY(${direction * 6}px)` : "translateY(0)"; 362 panel.getBoundingClientRect(); 363 364 panel.addEventListener("transitionend", handleTransitionEnd); 365 animationFrames.push( 366 window.requestAnimationFrame(() => { 367 panel.style.visibility = ""; 368 animationFrames.push( 369 window.requestAnimationFrame(() => { 370 panel.style.opacity = opening ? "1" : "0"; 371 panel.style.transform = opening ? "translateY(0)" : `translateY(${direction * 5}px)`; 372 }), 373 ); 374 }), 375 ); 376 timeoutId = window.setTimeout(finish, duration + 80); 377 378 return { 379 finished, 380 stop: () => { 381 animationFrames.forEach((frame) => window.cancelAnimationFrame(frame)); 382 const styles = window.getComputedStyle(panel); 383 panel.style.opacity = styles.opacity; 384 panel.style.transform = styles.transform; 385 finish(); 386 }, 387 }; 388 } 389 390 async prepareOverlayOpen({ wasHidden, animateChange, sequence }) { 391 const direction = this.isOverlayBelow() ? -1 : 1; 392 393 this.mountPanelForOverlay(); 394 this.panelElement.style.visibility = "hidden"; 395 this.panelElement.style.height = "auto"; 396 this.panelElement.style.opacity = animateChange && wasHidden ? "0" : "1"; 397 this.panelElement.style.transform = animateChange && wasHidden ? `translateY(${direction * 6}px)` : "translateY(0)"; 398 this.panelElement.hidden = false;
399 400 await this.positionOverlay(); 401 if (sequence !== this.panelOpenSequence || !this.openValue || !this.isOverlayMode()) return; 402 403 this.startOverlayPositioning(); 404 405 if (animateChange) { 406 this.panelAnimation = this.transitionOverlayPanel({ opening: true, wasHidden }); 407 } else { 408 this.panelElement.style.visibility = ""; 409 } 410 } 411 412 clearPendingTimeouts() { 413 this.timeoutIds.forEach((timeoutId) => window.clearTimeout(timeoutId)); 414 this.timeoutIds = []; 415 } 416 417 cloneTasks(tasks) { 418 return tasks.map((task) => ({ ...task })); 419 } 420 421 taskFor(taskId) { 422 return this.tasks.find(({ id }) => String(id) === String(taskId)); 423 } 424 425 checklistId() { 426 return this.config.checklistId || this.config.checklist_id || null; 427 } 428 429 bindUpdateEvent() { 430 this.unbindUpdateEvent(); 431 this.updateEventName = this.config.updateEvent || this.config.update_event || "onboarding-checklist:update"; 432 this.updateEventHandler = this.updateTasks.bind(this); 433 document.addEventListener(this.updateEventName, this.updateEventHandler); 434 } 435 436 unbindUpdateEvent() { 437 if (this.updateEventName && this.updateEventHandler) { 438 document.removeEventListener(this.updateEventName, this.updateEventHandler); 439 } 440 441 this.updateEventName = null; 442 this.updateEventHandler = null; 443 } 444 445 updateTasks(event) { 446 const detail = event.detail || {}; 447 const targetChecklistId = detail.checklistId || detail.checklist_id; 448 if (targetChecklistId && String(targetChecklistId) !== String(this.checklistId())) return; 449 450 const updates = Array.isArray(detail.tasks) ? detail.tasks : detail.task ? [detail.task] : []; 451 if (updates.length === 0 && !detail.replace) return; 452 453 const wasChecklistComplete = this.isChecklistComplete(); 454 const previouslyComplete = new Set( 455 this.tasks.filter(({ status }) => status === "completed").map(({ id }) => String(id)), 456 ); 457 458 if (detail.replace) { 459 this.tasks = this.cloneTasks(updates); 460 } else { 461 updates.forEach((update) => { 462 if (update?.id === undefined || update?.id === null) return; 463 464 const taskIndex = this.tasks.findIndex(({ id }) => String(id) === String(update.id)); 465 if (update.remove) { 466 if (taskIndex >= 0) this.tasks.splice(taskIndex, 1); 467 } else if (taskIndex >= 0) { 468 this.tasks[taskIndex] = { ...this.tasks[taskIndex], ...update }; 469 } else { 470 this.tasks.push({ ...update }); 471 } 472 }); 473 } 474 475 this.bindCompletionEvents(); 476 this.render(); 477 478 const newlyCompleted = this.tasks.filter( 479 (task) => task.status === "completed" && !previouslyComplete.has(String(task.id)), 480 ); 481 const latestCompleted = newlyCompleted.at(-1); 482 if (latestCompleted) { 483 this.announce(`${latestCompleted.title} completed`); 484 this.celebrate(latestCompleted.id); 485 } else if (!wasChecklistComplete && this.isChecklistComplete()) { 486 this.handleChecklistComplete(); 487 } 488 } 489 490 bindCompletionEvents() { 491 this.unbindCompletionEvents(); 492 this.completionEventHandlers = []; 493 494 this.tasks.forEach((task) => { 495 const eventName = task.completionEvent || task.completion_event; 496 const completionSelector = task.completionSelector || task.completion_selector; 497 const hasEvent = typeof eventName === "string" && eventName.trim() !== ""; 498 const hasSelector = typeof completionSelector === "string" && completionSelector.trim() !== ""; 499 if (!hasEvent && !hasSelector) return; 500 501 const handler = () => { 502 if (task.status === "completed" || task.status === "skipped") return; 503 504 task.status = "completed"; 505 this.render(); 506 this.announce(`${task.title} completed`); 507 this.celebrate(task.id); 508 this.persistTaskCompletion(task); 509 }; 510 511 if (hasEvent) { 512 document.addEventListener(eventName, handler); 513 this.completionEventHandlers.push({ eventName, handler }); 514 } 515 516 if (hasSelector) { 517 try { 518 if (document.querySelector(completionSelector)) window.queueMicrotask(handler); 519 } catch (_error) { 520 // Ignore invalid app-provided selectors without breaking the checklist. 521 } 522 } 523 }); 524 } 525 526 unbindCompletionEvents() { 527 this.completionEventHandlers?.forEach(({ eventName, handler }) => { 528 document.removeEventListener(eventName, handler); 529 }); 530 this.completionEventHandlers = []; 531 } 532 533 async persistTaskCompletion(task) { 534 const completionUrl = task.completionUrl || task.completion_url; 535 if (!completionUrl) return; 536 537 try { 538 const token = document.querySelector('meta[name="csrf-token"]')?.content; 539 const response = await fetch(completionUrl, { 540 method: task.completionMethod || task.completion_method || "PATCH", 541 headers: { 542 "Content-Type": "application/json", 543 Accept: "application/json", 544 ...(token ? { "X-CSRF-Token": token } : {}), 545 }, 546 body: JSON.stringify({ 547 task_id: task.id, 548 completed: true, 549 ...(task.completionPayload || task.completion_payload || {}), 550 }), 551 }); 552 553 if (!response.ok) throw new Error(`Request failed with ${response.status}`); 554 } catch (error) { 555 console.warn("Could not persist onboarding checklist completion.", error); 556 } 557 } 558 559 focusTask(event) { 560 const task = this.taskFor(event.currentTarget.dataset.taskId); 561 const selector = task?.focus || task?.focusSelector || task?.focus_selector; 562 if (!selector) return; 563 564 let target; 565 try { 566 target = document.querySelector(selector); 567 } catch (_error) { 568 return; 569 } 570 if (!target) return; 571 572 const revealTarget = () => { 573 target.scrollIntoView({ 574 behavior: this.reducedMotion.matches ? "auto" : "smooth", 575 block: "center", 576 }); 577 578 window.requestAnimationFrame(() => {
579 if (typeof target.focus === "function") target.focus({ preventScroll: true }); 580 }); 581 }; 582 583 if (this.openValue) { 584 this.openValue = false; 585 this.paintOpenState(); 586 this.panelAnimation?.finished.then(revealTarget); 587 } else { 588 revealTarget(); 589 } 590 } 591 592 async toggleSkip(event) { 593 const task = this.taskFor(event.currentTarget.dataset.taskId); 594 if (!task || task.status === "loading" || task.status === "completed") return; 595 596 const previousStatus = task.status; 597 const skipped = previousStatus !== "skipped"; 598 const skipUrl = task.skipUrl || task.skip_url; 599 task.status = "loading"; 600 this.render(); 601 602 if (skipUrl) { 603 try { 604 const token = document.querySelector('meta[name="csrf-token"]')?.content; 605 const response = await fetch(skipUrl, { 606 method: task.skipMethod || task.skip_method || "PATCH", 607 headers: { 608 "Content-Type": "application/json", 609 Accept: "application/json", 610 ...(token ? { "X-CSRF-Token": token } : {}), 611 }, 612 body: JSON.stringify({ 613 task_id: task.id, 614 ignored: skipped, 615 ...(task.skipPayload || task.skip_payload || {}), 616 }), 617 }); 618 619 if (!response.ok) throw new Error(`Request failed with ${response.status}`); 620 } catch (error) { 621 task.status = previousStatus; 622 this.render(); 623 this.announce(`Could not update ${task.title}`); 624 console.warn("Could not update onboarding checklist task.", error); 625 return; 626 } 627 } 628 629 task.status = skipped ? "skipped" : "pending"; 630 this.render(); 631 this.announce(skipped ? `${task.title} skipped` : `${task.title} restored`); 632 633 if (skipped && this.isChecklistComplete()) { 634 this.schedule(() => { 635 if (this.isChecklistComplete()) this.handleChecklistComplete(); 636 }, 480); 637 } 638 } 639 640 parseData() { 641 try { 642 const parsed = JSON.parse(this.dataValue || "{}"); 643 return parsed && typeof parsed === "object" ? parsed : {}; 644 } catch (error) { 645 console.warn("Onboarding checklist data must be valid JSON.", error); 646 return { tasks: [] }; 647 } 648 } 649 650 render() { 651 const progressTasks = this.tasks.filter(({ status }) => status !== "skipped"); 652 const completed = progressTasks.filter(({ status }) => status === "completed").length; 653 const total = progressTasks.length; 654 const percentage = total === 0 ? 0 : Math.round((completed / total) * 100); 655 const allComplete = total > 0 && completed === total; 656 if (!allComplete) { 657 this.completionAnnounced = false; 658 this.dismissing = false; 659 } 660 661 if (this.hasTitleTarget) { 662 this.titleTarget.textContent = this.config.title || "Make this workspace yours"; 663 } 664 if (this.panelTitleElement) { 665 this.panelTitleElement.textContent = this.config.panelTitle || this.config.panel_title || "Your quick start"; 666 } 667 if (this.panelDescriptionElement) { 668 this.panelDescriptionElement.textContent = 669 this.config.panelDescription || this.config.panel_description || "A few small wins, then youâre flying."; 670 } 671 672 this.progressElement?.setAttribute("aria-label", `${percentage}% complete`); 673 if (this.summaryElement) { 674 this.summaryElement.textContent = allComplete 675 ? this.config.completedSummary || this.config.completed_summary || "All set! Your workspace is ready" 676 : `${completed} of ${total} steps complete`; 677 } 678 if (this.remainingElement) { 679 this.remainingElement.textContent = allComplete ? "Complete" : `${total - completed} remaining`; 680 } 681 682 this.ringElement?.classList.toggle("text-neutral-800", !allComplete); 683 this.ringElement?.classList.toggle("dark:text-neutral-200", !allComplete); 684 this.ringElement?.classList.toggle("text-emerald-500", allComplete); 685 this.progressElement?.classList.toggle("text-neutral-700", !allComplete); 686 this.progressElement?.classList.toggle("dark:text-neutral-200", !allComplete); 687 this.progressElement?.classList.toggle("text-emerald-600", allC
687omplete); 688 this.progressElement?.classList.toggle("dark:text-emerald-400", allComplete); 689 690 this.updateProgress(percentage); 691 692 if (this.demoButtonElement) { 693 this.demoButtonElement.disabled = this.batchRunning; 694 this.demoButtonElement.textContent = allComplete 695 ? "Replay progress" 696 : this.batchRunning 697 ? "Completingâ¦" 698 : "Complete all"; 699 } 700 701 this.taskListElement.replaceChildren(...this.tasks.map((task, index) => this.taskElement(task, index))); 702 } 703 704 updateProgress(percentage) { 705 if (!this.ringElement && !this.progressElement) { 706 this.progressInitialized = true; 707 this.currentPercentage = percentage; 708 this.revealData({ animate: false }); 709 return; 710 } 711 712 if (this.ringElement) this.ringElement.style.strokeDasharray = "100"; 713 714 if (this.progressInitialized) { 715 if (this.ringAnimationFrame !== null) { 716 this.pendingInitialPercentage = percentage; 717 } else { 718 this.applyProgress(percentage); 719 } 720 return; 721 } 722 723 this.progressInitialized = true; 724 this.pendingInitialPercentage = percentage; 725 if (this.ringElement) this.ringElement.style.strokeDashoffset = "100"; 726 727 if (this.reducedMotion.matches) { 728 this.applyProgress(percentage); 729 this.revealData({ animate: false }); 730 return; 731 } 732 733 this.ringAnimationFrame = window.requestAnimationFrame(() => { 734 this.ringAnimationFrame = window.requestAnimationFrame(() => { 735 this.ringAnimationFrame = null; 736 this.applyProgress(this.pendingInitialPercentage); 737 this.revealData(); 738 }); 739 }); 740 } 741 742 applyProgress(percentage) { 743 if (percentage < 100) this.stopSuccessAnimations(); 744 745 if (this.currentPercentage !== percentage && this.progressElement) { 746 this.progressElement.trend = Math.sign(percentage - this.currentPercentage); 747 this.progressElement.update(percentage); 748 } 749 this.currentPercentage = percentage; 750 751 if (this.ringElement) this.ringElement.style.strokeDashoffset = String(100 - percentage); 752 } 753 754 revealData({ animate: shouldAnimate = true } = {}) { 755 this.readyTargets.forEach((element) => element.removeAttribute("aria-hidden")); 756 757 if (!shouldAnimate) 758 return this.readyTargets.forEach((element) => { 759 element.style.opacity = "1"; 760 }); 761 762 window.requestAnimationFrame(() => { 763 this.readyTargets.forEach((element) => { 764 element.style.opacity = "1"; 765 }); 766 }); 767 } 768 769 configureProgressFlow() { 770 if (!this.progressElement) return; 771 772 this.progressElement.numberSuffix = "%"; 773 this.progressElement.plugins = [continuous]; 774 this.progressElement.isolate = true; 775 this.progressElement.willChange = true; 776 this.progressElement.respectMotionPreference = true; 777 this.progressElement.spinTiming = { 778 duration: 480, 779 easing: "cubic-bezier(0.65, 0, 0.35, 1)", 780 }; 781 this.progressElement.transformTiming = { 782 duration: 480, 783 easing: "cubic-bezier(0.65, 0, 0.35, 1)", 784 }; 785 this.progressElement.opacityTiming = { 786 duration: 180, 787 easing: "ease-out", 788 }; 789 } 790 791 taskElement(task, index) { 792 const compact = this.compactValue; 793 const item = document.createElement("li"); 794 item.className = "group relative will-change-transform"; 795 item.dataset.onboardingChecklistTask = ""; 796 item.dataset.taskId = task.id; 797 798 if (index > 0) { 799 const divider = document.createElement("span"); 800 divider.className = `pointer-events-none absolute ${compact ? "inset-x-4" : "inset-x-5"} top-0 h-px bg-neutral-200/70 dark:bg-white/8`; 801 item.append(divider); 802 } 803 804 const taskUrl = this.safeTaskUrl(task.url); 805 const focusSelector = task.focus || task.focusSelector || task.focus_selector; 806 const actionEvent = task.actionEvent || task.action_event; 807 const interactive = Boolean(taskUrl || focusSelector || actionEvent); 808 const content = document.createElement("div"); 809 content.className = compact 810 ? "flex min-h-12 w-full items-center gap-1.5 px-4 py-3" 811 : "flex min-h-[58px] w-full items-center gap-2 px-5 py-3.5"; 812 813 const main = document.createElement(taskUrl ? "a" : focusSelector || actionEvent ? "button" : "div"); 814 main.className = interactive 815 ? `flex min-w-0 flex-1 items-center ${compact ? "gap-2.5" : "gap-3"} rounded-lg text-left transition-colors duration-150 ease-out hover:text-neutral-950 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neutral-500 motion-reduce:transition-none dark:hover:text-white` 816 : `flex min-w-0 flex-1 items-center ${compact ? "gap-2.5" : "gap-3"}`; 817 818 if (taskUrl) { 819 main.href = taskUrl; 820 if (task.target === "_blank") { 821 main.target = "_blank"; 822 main.rel = "noopener noreferrer"; 823 } 824 } else if (focusSelector) { 825 main.type = "button"; 826 main.dataset.action = "click->onboarding-checklist#focusTask"; 827 main.dataset.taskId = task.id; 828 } else if (actionEvent) { 829 main.type = "button"; 830 main.dataset.action = "click->onboarding-checklist#dispatchTaskAction"; 831 main.dataset.taskId = task.id; 832 } 833 834 if (main instanceof HTMLButtonElement) { 835 main.disabled = task.status === "loading"; 836 } 837 838 const status = document.createElement("span"); 839 status.className = this.statusClasses(task.status, task); 840 status.setAttribute("aria-hidden", "true"); 841 status.append(this.statusIcon(task.status, index, task)); 842 843 const copy = document.createElement("span"); 844 copy.className = "min-w-0 flex-1"; 845 846 const titleRow = document.createElement("span"); 847 titleRow.className = "flex min-w-0 items-center gap-1.5"; 848 849 const title = document.createElement("span"); 850 title.className = 851 task.status === "completed" 852 ? `block truncate ${compact ? "text-xs" : "text-sm"} font-medium text-neutral-400 line-through decoration-neutral-300 dark:text-neutral-500 dark:decoration-neutral-600` 853 : task.status === "skipped" 854 ? `block truncate ${compact ? "text-xs" : "text-sm"} font-medium text-neutral-400 dark:text-neutral-500` 855 : `block truncate ${compact ? "text-xs" : "text-sm"} font-medium text-neutral-900 dark:text-white`; 856 title.textContent = task.title; 857 titleRow.append(title); 858 859 if (task.optional) { 860 const optional = document.createElement("span"); 861 optional.className = `inline-flex shrink-0 items-center rounded-md bg-neutral-100 ${compact ? "px-1 py-px text-[8px]" : "px-1.5 py-0.5 text-[9px]"} font-medium text-neutral-500 ring-1 ring-inset ring-neutral-200 dark:bg-white/5 dark:text-neutral-400 dark:ring-white/10`; 862 optional.textContent = "Optional"; 863 titleRow.append(optional); 864 } 865 866 const description = document.createElement("span"); 867 description.className = `mt-0.5 block truncate ${compact ? "text-[10px]" : "text-xs"} text-neutral-500 dark:text-neutral-400`; 868 description.textContent = 869 task.status === "skipped" 870 ? task.skippedDescription || task.skipped_description || "Skipped for now" 871 : task.description || ""; 872 copy.append(titleRow, description); 873 874 main.append(status, copy); 875 876 if (interactive) { 877 const destination = document.createElement("span"); 878 destination.className = `flex shrink-0 items-center gap-1 ${compact ? "text-[10px]" : "text-[11px]"} font-semibold text-neutral-500 transition-colors duration-150 group-hover:text-neutral-950 motion-reduce:transition-none dark:text-neutral-400 dark:group-hover:text-white`; 879 if (!compact) destination.append(task.action || (task.status === "completed" ? "View" : "Continue")); 880 destination.append( 881 this.svg( 882 "m6.5 4.5 4.5 4.5-4.5 4.5", 883 `${compact ? "size-3" : "size-3.5"} transition-transform duration-150 ease-out group-hover:translate-x-0.5 motion-reduce:transition-none`, 884 "none", 885 "currentColor", 886 ), 887 ); 888 main.append(destination); 889 } else if (task.status !== "completed") { 890 const action = document.createElement("button"); 891 action.type = "button"; 892 action.dataset.action = "click->onboarding-checklist#complete"; 893 action.dataset.taskId = task.id; 894 action.disabled = this.batchRunning || task.status === "loading"; 895 action.className = `flex ${compact ? "min-h-7 px-2.5 text-[10px]" : "min-h-9 px-3 text-xs"} shrink-0 items-center justify-center rounded-lg bg-neutral-900 font-medium text-white shadow-sm transition-colors duration-150 ease-out motion-reduce:transition-none hover:bg-neutral-700 disabled:cursor-wait disabled:bg-neutral-200 disabled:text-neutral-500 dark:bg-white dark:text-neutral-950 dar
895k:hover:bg-neutral-200 dark:disabled:bg-neutral-700 dark:disabled:text-neutral-400`; 896 action.textContent = task.status === "loading" ? "Workingâ¦" : task.action || "Start"; 897 main.append(action); 898 } 899 900 content.append(main); 901 902 if (task.optional && task.status !== "completed" && task.status !== "loading") { 903 const skip = document.createElement("button"); 904 skip.type = "button"; 905 skip.dataset.action = "click->onboarding-checklist#toggleSkip"; 906 skip.dataset.taskId = task.id; 907 skip.className = `${compact ? "min-h-7 px-1.5 text-[9px]" : "min-h-8 px-2 text-[10px]"} shrink-0 rounded-md font-medium text-neutral-400 transition-colors duration-150 hover:bg-neutral-100 hover:text-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 motion-reduce:transition-none dark:text-neutral-500 dark:hover:bg-white/8 dark:hover:text-neutral-200`; 908 skip.textContent = task.status === "skipped" ? "Restore" : "Skip"; 909 content.append(skip); 910 } 911 912 item.append(content); 913 return item; 914 } 915 916 safeTaskUrl(value) { 917 if (typeof value !== "string" || value.trim() === "") return null; 918 919 try { 920 const url = new URL(value, window.location.origin); 921 return ["http:", "https:"].includes(url.protocol) ? value : null; 922 } catch (_error) { 923 return null; 924 } 925 } 926 927 statusClasses(status, task = {}) { 928 const base = `flex ${this.compactValue ? "size-6" : "size-7"} shrink-0 items-center justify-center rounded-full will-change-transform`; 929 if (status === "completed") 930 return `${base} bg-emerald-500 text-white shadow-[0_1px_3px_rgb(16_185_129_/_25%)] border border-emerald-600 dark:border-emerald-400`; 931 if (status === "loading") 932 return `${base} bg-amber-50 text-amber-600 ring-1 ring-amber-200 dark:bg-amber-400/10 dark:text-amber-300 dark:ring-amber-400/20`; 933 if (status === "skipped") 934 return `${base} bg-neutral-50 text-neutral-400 ring-1 ring-inset ring-neutral-200 dark:bg-white/5 dark:text-neutral-500 dark:ring-white/10`; 935 if (task.icon === "chrome") 936 return `${base} bg-neutral-900 text-white shadow-sm ring-1 ring-inset ring-black/10 dark:bg-white dark:text-neutral-950 dark:ring-white/10`; 937 return `${base} bg-neutral-100 text-neutral-500 ring-1 ring-inset ring-neutral-200 dark:bg-neutral-800 dark:text-neutral-400 dark:ring-neutral-700`; 938 } 939 940 statusIcon(status, index, task = {}) { 941 if (status === "completed") { 942 const icon = this.svg("M5 9.5 8 12.5 14 6.5", this.compactValue ? "size-3.5" : "size-4", "none", "currentColor"); 943 icon.querySelector("path")?.setAttribute("pathLength", "1"); 944 return icon; 945 } 946 947 if (status === "loading") { 948 const spinner = this.loadingSpinner(); 949 spinner.setAttribute("aria-label", "Loading"); 950 return spinner; 951 } 952 953 if (status === "skipped") { 954 return this.svg("M5.25 9h7.5", this.compactValue ? "size-3" : "size-3.5", "none", "currentColor"); 955 } 956 957 const taskIcon = this.taskIcon(task); 958 if (taskIcon) return taskIcon; 959 960 const number = document.createElement("span"); 961 number.className = `${this.compactValue ? "text-[10px]" : "text-[11px]"} font-semibold tabular-nums`; 962 number.textContent = String(index + 1); 963 return number; 964 } 965 966 taskIcon(task) { 967 if (task.icon === "chrome") return this.chromeIcon(); 968 969 const customPaths = task.iconPaths || task.icon_paths || task.iconPath || task.icon_path; 970 if (customPaths) { 971 return this.pathsIcon(Array.isArray(customPaths) ? customPaths : [customPaths], {
972 viewBox: task.iconViewBox || task.icon_view_box || "0 0 18 18", 973 filled: task.iconFill || task.icon_fill, 974 }); 975 } 976 977 const icons = { 978 user: ["M9 9.25a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm-5.25 5.5c.35-2.35 2.42-4 5.25-4s4.9 1.65 5.25 4"], 979 profile: ["M9 9.25a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm-5.25 5.5c.35-2.35 2.42-4 5.25-4s4.9 1.65 5.25 4"], 980 folder: [ 981 "M2.75 6.5V5.25c0-.83.67-1.5 1.5-1.5h2.4l1.4 1.75h5.7c.83 0 1.5.67 1.5 1.5v6.25c0 .83-.67 1.5-1.5 1.5h-9.5c-.83 0-1.5-.67-1.5-1.5V6.5Zm0 0h12.5", 982 ], 983 workspace: [ 984 "M2.75 6.5V5.25c0-.83.67-1.5 1.5-1.5h2.4l1.4 1.75h5.7c.83 0 1.5.67 1.5 1.5v6.25c0 .83-.67 1.5-1.5 1.5h-9.5c-.83 0-1.5-.67-1.5-1.5V6.5Zm0 0h12.5", 985 ], 986 users: [ 987 "M7.25 8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm-4 6c.3-2.25 1.75-3.75 4-3.75s3.7 1.5 4 3.75m.25-10.6a2.5 2.5 0 0 1 0 4.7m1.25 2.15c1.2.55 1.85 1.75 2 3.75", 988 ], 989 sparkles: [ 990 "m8.75 2 .65 2.1a4.9 4.9 0 0 0 3.25 3.25l2.1.65-2.1.65A4.9 4.9 0 0 0 9.4 11.9L8.75 14l-.65-2.1a4.9 4.9 0 0 0-3.25-3.25L2.75 8l2.1-.65A4.9 4.9 0 0 0 8.1 4.1L8.75 2Zm5.1 9.75.3.95c.2.65.7 1.15 1.35 1.35l.95.3-.95.3c-.65.2-1.15.7-1.35 1.35l-.3.95-.3-.95a2.1 2.1 0 0 0-1.35-1.35l-.95-.3.95-.3a2.1 2.1 0 0 0 1.35-1.35l.3-.95Z", 991 ], 992 bookmark: ["M5 3.25h8c.69 0 1.25.56 1.25 1.25v10.25L9 11.6l-5.25 3.15V4.5C3.75 3.81 4.31 3.25 5 3.25Z"], 993 bell: ["M4.25 12.5h9.5l-1.15-1.65V8a3.6 3.6 0 0 0-7.2 0v2.85L4.25 12.5Zm3.1 2a1.75 1.75 0 0 0 3.3 0"], 994 link: [ 995 "M7.2 10.8 10.8 7.2m-5.05 5.05-1.1 1.1a2.12 2.12 0 1 1-3-3l2.1-2.1a2.12 2.12 0 0 1 3 0m4.5 1.5a2.12 2.12 0 0 0 3 0l2.1-2.1a2.12 2.12 0 1 0-3-3l-1.1 1.1", 996 ], 997 keyboard: [ 998 "M3.25 4.25h11.5c.83 0 1.5.67 1.5 1.5v6.5c0 .83-.67 1.5-1.5 1.5H3.25c-.83 0-1.5-.67-1.5-1.5v-6.5c0-.83.67-1.5 1.5-1.5Zm1.25 3h.01m2.49 0h.01m2.49 0h.01m2.49 0h.01m-7 3h7", 999 ], 1000 }; 1001 1002 return icons[task.icon] ? this.pathsIcon(icons[task.icon]) : null; 1003 } 1004 1005 pathsIcon(paths, { viewBox = "0 0 18 18", filled = false } = {}) { 1006 const namespace = "http://www.w3.org/2000/svg"; 1007 const icon = document.createElementNS(namespace, "svg"); 1008 icon.setAttribute("viewBox", viewBox); 1009 icon.setAttribute("class", this.compactValue ? "size-3" : "size-3.5"); 1010 icon.setAttribute("fill", filled ? "currentColor" : "none"); 1011 icon.setAttribute("stroke", filled ? "none" : "currentColor"); 1012 icon.setAttribute("stroke-width", "1.5"); 1013 icon.setAttribute("stroke-linecap", "round"); 1014 icon.setAttribute("stroke-linejoin", "round"); 1015 1016 paths.filter(Boolean).forEach((pathData) => { 1017 const path = document.createElementNS(namespace, "path"); 1018 path.setAttribute("d", pathData); 1019 icon.append(path); 1020 }); 1021 1022 return icon; 1023 } 1024 1025 chromeIcon() { 1026 const namespace = "http://www.w3.org/2000/svg"; 1027 const icon = document.createElementNS(namespace, "svg"); 1028 icon.setAttribute("viewBox", "0 0 32 32"); 1029 icon.setAttribute("class", "size-3.5"); 1030 icon.setAttribute("fill", "currentColor"); 1031 1032 [ 1033 "M9.138 14.616C9.783 11.417 12.614 9 16 9h12.106C25.681 4.823 21.169 2 16 2 11.522 2 7.539 4.121 4.974 7.403l4.164 7.213Z", 1034 "M18.236 22.627A6.99 6.99 0 0 1 16 23a6.997 6.997 0 0 1-6.055-3.504l-.008.004-6.052-10.483A13.925 13.925 0 0 0 2 16c0 7.061 5.261 12.903 12.065 13.85l4.171-7.223Z", 1035 "M20.615 10.75A6.983 6.983 0 0 1 23 16a6.96 6.96 0 0 1-.945 3.496l.008.004L16 30c7.719 0 14-6.281 14-14 0-1.857-.371-3.627-1.031-5.25h-8.354Z", 1036 ].forEach((pathData) => { 1037 const path = document.createElementNS(namespace, "path"); 1038 path.setAttribute("d", pathData); 1039 icon.append(path); 1040 }); 1041 1042 const circle = document.createElementNS(namespace, "circle"); 1043 circle.setAttribute("cx", "16"); 1044 circle.setAttribute("cy", "16"); 1045 circle.setAttribute("r", "5.25"); 1046 icon.append(circle); 1047 return icon; 1048 } 1049 1050 loadingSpinner() { 1051 const namespace = "http://www.w3.org/2000/svg"; 1052 const spinner = document.createElementNS(namespace, "svg"); 1053 spinner.setAttribute("viewBox", "0 0 18 18"); 1054 spinner.setAttribute( 1055 "class", 1056 `${this.compactValue ? "size-3.5" : "size-4"} animate-spin rounded-full motion-reduce:animate-none`, 1057 ); 1058 spinner.setAttribute("fill", "currentColor"); 1059 1060 const track = document.createElementNS(namespace, "path"); 1061 track.setAttribute("d", "M9 17a8 8 0 1 1 0-16 8 8 0 0 1 0 16Zm0-14.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Z");
1062 track.setAttribute("opacity", "0.32"); 1063 1064 const lead = document.createElementNS(namespace, "path"); 1065 lead.setAttribute( 1066 "d", 1067 "M16.25 9.75a.75.75 0 0 1-.75-.75A6.5 6.5 0 0 0 9 2.5.75.75 0 0 1 9 1a8 8 0 0 1 8 8 .75.75 0 0 1-.75.75Z", 1068 ); 1069 1070 spinner.append(track, lead); 1071 return spinner; 1072 } 1073 1074 svg(pathData, className, fill, stroke) { 1075 const namespace = "http://www.w3.org/2000/svg"; 1076 const svg = document.createElementNS(namespace, "svg"); 1077 svg.setAttribute("viewBox", "0 0 18 18"); 1078 svg.setAttribute("class", className); 1079 svg.setAttribute("fill", fill); 1080 svg.setAttribute("stroke", stroke); 1081 svg.setAttribute("stroke-width", "1.8"); 1082 svg.setAttribute("stroke-linecap", "round"); 1083 svg.setAttribute("stroke-linejoin", "round"); 1084 1085 const path = document.createElementNS(namespace, "path"); 1086 path.setAttribute("d", pathData); 1087 svg.append(path); 1088 return svg; 1089 } 1090 1091 applyMode() { 1092 const mode = this.normalizedMode(); 1093 const overlayAbove = mode === "overlay-above"; 1094 const overlayBelow = mode === "overlay-below"; 1095 const overlay = overlayAbove || overlayBelow; 1096 1097 // Match the dropdown portal: document-relative absolute positioning lets 1098 // normal page scrolling move the anchor and panel together without the 1099 // panel visually chasing the anchor on every scroll event. 1100 this.panelElement.classList.toggle("absolute", overlay); 1101 this.panelElement.classList.toggle("mt-2.5", !overlay); 1102 this.panelElement.classList.toggle("origin-bottom", overlayAbove); 1103 this.panelElement.classList.toggle("origin-top", overlayBelow || !overlay); 1104 this.setSurfaceTransitionDuration(this.panelElement, { 1105 opening: this.openValue, 1106 overlay, 1107 }); 1108 1109 if (overlay && this.openValue) { 1110 this.mountPanelForOverlay(); 1111 this.startOverlayPositioning(); 1112 } else if (!overlay) { 1113 this.stopOverlayPositioning(); 1114 this.restorePanelMount(); 1115 this.clearOverlayPositionStyles(); 1116 } 1117 1118 this.modeButtonTargets.forEach((button) => { 1119 const active = button.dataset.mode === mode; 1120 button.setAttribute("aria-pressed", String(active)); 1121 button.classList.toggle("bg-white", active); 1122 button.classList.toggle("text-neutral-900", active); 1123 button.classList.toggle("shadow-sm", active); 1124 button.classList.toggle("dark:bg-neutral-700", active); 1125 button.classList.toggle("dark:text-white", active); 1126 }); 1127 } 1128 1129 normalizedMode() { 1130 return this.modeValue === "overlay" ? "overlay-above" : this.modeValue; 1131 } 1132 1133 isOverlayMode() { 1134 return ["overlay-above", "overlay-below"].includes(this.normalizedMode()); 1135 } 1136 1137 isOverlayBelow() { 1138 return this.normalizedMode() === "overlay-below"; 1139 } 1140 1141 mountPanelForOverlay() { 1142 const appendTarget = this.element.closest("dialog[open]") || document.body; 1143 if (this.panelElement.parentElement !== appendTarget) appendTarget.appendChild(this.panelElement); 1144 this.isPanelPortaled = this.panelElement.parentElement !== this.originalPanelParent; 1145 } 1146 1147 restorePanelMount() { 1148 if (!this.isPanelPortaled || !this.originalPanelParent) return; 1149 1150 if (this.originalPanelNextSibling?.parentNode === this.originalPanelParent) { 1151 this.originalPanelParent.insertBefore(this.panelElement, this.originalPanelNextSibling); 1152 } else { 1153 this.originalPanelParent.appendChild(this.panelElement); 1154 } 1155 1156 this.isPanelPortaled = false; 1157 } 1158 1159 startOverlayPositioning() { 1160 this.stopOverlayPositioning(); 1161 this.overlayPositionCleanup = autoUpdate(this.surfaceElement, this.panelElement, () => this.positionOverlay()); 1162 } 1163 1164 stopOverlayPositioning() { 1165 this.overlayPositionCleanup?.(); 1166 this.overlayPositionCleanup = null; 1167 } 1168 1169 async positionOverlay() { 1170 if (!this.openValue || !this.isOverlayMode()) return; 1171 1172 const anchorWidth = this.surfaceElement.getBoundingClientRect().width; 1173 this.panelElement.style.width = `${anchorWidth}px`; 1174 1175 const { x, y } = await computePosition(this.surfaceElement, this.panelElement, { 1176 placement: this.isOverlayBelow() ? "bottom" : "top", 1177 middleware: [offset(10), shift({ padding: 12 })], 1178 }); 1179 1180 if (!this.openValue || !this.isOverlayMode()) return;
1181 Object.assign(this.panelElement.style, { left: `${x}px`, top: `${y}px` }); 1182 } 1183 1184 clearOverlayPositionStyles() { 1185 this.panelElement.style.width = ""; 1186 this.panelElement.style.left = ""; 1187 this.panelElement.style.top = ""; 1188 this.panelElement.style.visibility = ""; 1189 } 1190 1191 paintOpenState({ animate: shouldAnimate = true } = {}) { 1192 const sequence = ++this.panelOpenSequence; 1193 const open = this.openValue; 1194 const animateChange = shouldAnimate && !this.reducedMotion.matches; 1195 const wasHidden = this.panelElement.hidden; 1196 1197 this.stopPanelAnimations(); 1198 1199 this.triggerElement?.setAttribute("aria-expanded", String(open)); 1200 if (this.chevronElement) { 1201 this.setSurfaceTransitionDuration(this.chevronElement, { opening: open }); 1202 this.chevronElement.style.transform = open ? "rotate(180deg)" : "rotate(0deg)"; 1203 } 1204 this.panelElement.toggleAttribute("inert", !open); 1205 1206 if (open) { 1207 if (this.isOverlayMode()) { 1208 this.prepareOverlayOpen({ wasHidden, animateChange, sequence }); 1209 } else { 1210 this.panelElement.hidden = false; 1211 if (animateChange) { 1212 this.animateIn({ wasHidden }); 1213 } else { 1214 this.panelElement.style.height = "auto"; 1215 this.panelElement.style.opacity = "1"; 1216 this.panelElement.style.transform = "translateY(0)"; 1217 } 1218 } 1219 } else if (this.panelElement.style.visibility === "hidden") { 1220 this.panelElement.hidden = true; 1221 this.panelElement.style.height = ""; 1222 this.panelElement.style.opacity = ""; 1223 this.panelElement.style.transform = ""; 1224 this.stopOverlayPositioning(); 1225 this.restorePanelMount(); 1226 this.clearOverlayPositionStyles(); 1227 } else if (animateChange && !this.panelElement.hidden) { 1228 this.animateOut(); 1229 } else { 1230 this.panelElement.hidden = true; 1231 this.panelElement.style.height = ""; 1232 this.panelElement.style.opacity = ""; 1233 this.panelElement.style.transform = ""; 1234 this.stopOverlayPositioning(); 1235 this.restorePanelMount(); 1236 this.clearOverlayPositionStyles(); 1237 } 1238 } 1239 1240 animateIn({ wasHidden }) { 1241 if (this.isOverlayMode()) { 1242 this.panelAnimation = this.transitionOverlayPanel({ opening: true, wasHidden }); 1243 } else { 1244 this.panelAnimation = this.transitionInlinePanel({ opening: true, wasHidden }); 1245 1246 this.panelAnimation.finished.then(() => { 1247 if (this.openValue && this.modeValue === "inline") this.panelElement.style.height = "auto"; 1248 }); 1249 } 1250 } 1251 1252 animateOut() { 1253 this.panelAnimation = this.isOverlayMode() 1254 ? this.transitionOverlayPanel({ opening: false }) 1255 : this.transitionInlinePanel({ opening: false }); 1256 1257 this.panelAnimation.finished.then(() => { 1258 if (!this.openValue) { 1259 this.panelElement.hidden = true; 1260 this.panelElement.style.height = ""; 1261 this.panelElement.style.opacity = ""; 1262 this.panelElement.style.transform = ""; 1263 this.stopOverlayPositioning(); 1264 this.restorePanelMount(); 1265 this.clearOverlayPositionStyles(); 1266 } 1267 }); 1268 } 1269 1270 celebrate(taskId) { 1271 if (this.reducedMotion.matches) { 1272 if (this.isChecklistComplete()) this.handleChecklistComplete(); 1273 return; 1274 } 1275 1276 this.completionAnimations.forEach((animation) => animation.stop?.()); 1277 1278 const completedStatus = this.taskListElement.querySelector( 1279 `[data-task-id="${CSS.escape(String(taskId))}"] .bg-emerald-500`, 1280 ); 1281 if (completedStatus) { 1282 const checkmarkPath = completedStatus.querySelector("svg path"); 1283 const statusAnimation = this.animateElement( 1284 completedStatus, 1285 [ 1286 { opacity: 0.25, transform: "scale(0.86)", offset: 0 }, 1287 { opacity: 1, transform: "scale(1.04)", offset: 0.58 }, 1288 { opacity: 1, transform: "scale(0.995)", offset: 0.82 }, 1289 { opacity: 1, transform: "scale(1)", offset: 1 }, 1290 ], 1291 { 1292 duration: 680, 1293 easing: "cubic-bezier(0.22, 1, 0.36, 1)", 1294 }, 1295 ); 1296 1297 const checkmarkAnimation = 1298 checkmarkPath && 1299 this.animateElement( 1300 checkmarkPath, 1301 [ 1302 { opacity: 0, strokeDasharray: "1 1", strokeDashoffset: 1 }, 1303 { opacity: 1, strokeDasharray: "1 1", strokeDashoffset: 0 }, 1304 ], 1305 { 1306 delay: 150, 1307 duration: 460, 1308 easing: "cubic-bezier(0.22, 1, 0.36, 1)", 1309 fill: "both", 1310 }, 1311 ); 1312 1313 this.completionAnimations = [statusAnimation, checkmarkAnimation].filter(
1313Boolean); 1314 } else { 1315 this.completionAnimations = []; 1316 } 1317 1318 if (this.isChecklistComplete()) { 1319 this.schedule(() => { 1320 if (this.isChecklistComplete()) { 1321 this.handleChecklistComplete(); 1322 } 1323 }, 480); 1324 } 1325 } 1326 1327 isChecklistComplete() { 1328 const progressTasks = this.tasks.filter(({ status }) => status !== "skipped"); 1329 return progressTasks.length > 0 && progressTasks.every(({ status }) => status === "completed"); 1330 } 1331 1332 handleChecklistComplete() { 1333 if (this.completionAnnounced || !this.isChecklistComplete()) return; 1334 1335 this.completionAnnounced = true; 1336 const eventName = this.config.completedEvent || this.config.completed_event || "onboarding-checklist:completed"; 1337 1338 this.element.dispatchEvent( 1339 new CustomEvent(eventName, { 1340 bubbles: true, 1341 detail: { 1342 checklistId: this.checklistId(), 1343 checklist_id: this.checklistId(), 1344 tasks: this.cloneTasks(this.tasks), 1345 }, 1346 }), 1347 ); 1348 1349 if (!this.reducedMotion.matches) this.celebrateChecklist(); 1350 1351 if (this.config.dismissOnComplete || this.config.dismiss_on_complete) { 1352 const delay = Number(this.config.dismissDelay ?? this.config.dismiss_delay ?? 1200); 1353 this.schedule( 1354 () => { 1355 if (this.isChecklistComplete()) this.dismissChecklist(); 1356 }, 1357 Math.max(0, delay), 1358 ); 1359 } 1360 } 1361 1362 dismissChecklist() { 1363 if (this.dismissing) return; 1364 1365 this.dismissing = true; 1366 this.openValue = false; 1367 this.paintOpenState({ animate: false }); 1368 this.element.style.overflow = "hidden"; 1369 1370 const remove = () => this.element.remove(); 1371 if (this.reducedMotion.matches) { 1372 remove(); 1373 return; 1374 } 1375 1376 const duration = Number(this.config.dismissDuration ?? this.config.dismiss_duration ?? 420); 1377 const animation = this.animateElement( 1378 this.element, 1379 [ 1380 { opacity: 1, transform: "translateY(0)", height: `${this.element.offsetHeight}px` }, 1381 { opacity: 0, transform: "translateY(-6px)", height: "0px" }, 1382 ], 1383 { 1384 duration: Math.max(0, duration), 1385 easing: "cubic-bezier(0.35, 0.75, 0, 1)", 1386 }, 1387 ); 1388 1389 animation.finished.then(remove); 1390 } 1391 1392 celebrateChecklist() { 1393 this.stopSuccessAnimations(); 1394 1395 const haloAnimation = 1396 this.completionHaloElement && 1397 this.animateElement( 1398 this.completionHaloElement, 1399 { 1400 opacity: [0, 0.52, 0.2, 0], 1401 transform: ["scale(0.68)", "scale(1.55)", "scale(1.95)", "scale(2.15)"], 1402 }, 1403 { duration: 1050, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }, 1404 ); 1405 1406 const progressAnimation = 1407 this.progressVisualElement && 1408 this.animateElement( 1409 this.progressVisualElement, 1410 { 1411 transform: ["scale(1)", "scale(1.1)", "scale(1.1)", "scale(0.985)", "scale(1)"], 1412 }, 1413 { duration: 760, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }, 1414 ); 1415 1416 const particleVectors = [ 1417 { x: -18, y: -20, color: "bg-emerald-400", delay: 0 }, 1418 { x: 1, y: -25, color: "bg-neutral-400", delay: 0.03 }, 1419 { x: 19, y: -18, color: "bg-emerald-400", delay: 0.06 }, 1420 { x: 25, y: 1, color: "bg-neutral-400", delay: 0.09 }, 1421 { x: 17, y: 20, color: "bg-emerald-400", delay: 0.12 }, 1422 { x: -2, y: 25, color: "bg-neutral-400", delay: 0.15 }, 1423 { x: -21, y: 17, color: "bg-emerald-400", delay: 0.18 }, 1424 { x: -25, y: -3, color: "bg-neutral-400", delay: 0.21 }, 1425 ]; 1426 1427 const particleAnimations = this.progressVisualElement 1428 ? particleVectors.map(({ x, y, color, delay }) => { 1429 const particle = document.createElement("span"); 1430 particle.className = `pointer-events-none absolute left-1/2 top-1/2 size-[3px] rounded-full ${color} will-change-transform`; 1431 particle.setAttribute("aria-hidden", "true"); 1432 this.progressVisualElement.append(particle); 1433 this.successParticles.push(particle); 1434 1435 const animation = this.animateElement( 1436 particle, 1437 { 1438 opacity: [0, 0.9, 0], 1439 transform: [ 1440 "translate(-50%, -50%) scale(0.5)", 1441 `translate(calc(-50% + ${x}px), calc(-50% + ${y}px)) scale(1.15)`, 1442 `translate(calc(-50% + ${x * 1.18}px), calc(-50% + ${y * 1.18}px)) scale(0.6)`, 1443 ], 1444 }, 1445 { 1446 delay: delay * 1000, 1447 duration: 860, 1448 easing: "cubic-bezier(0.22, 1, 0.36, 1)", 1449 }, 1450 ); 1451 1452 animation.finished.then(() => { 1453 particle.remove(); 1454 this.successParticles = this.successParticles.filter((element) => element !== particle); 1455 }); 1456 1457 return animation; 1458 }) 1459 : []; 1460 1461 this.successAnimations = [haloAnimation, progressAnimation, ...particleAnimations].filter(
1461Boolean); 1462 } 1463 1464 stopSuccessAnimations() { 1465 this.successAnimations.forEach((animation) => animation.stop?.()); 1466 this.successParticles.forEach((particle) => particle.remove()); 1467 this.successAnimations = []; 1468 this.successParticles = []; 1469 1470 if (this.completionHaloElement) { 1471 this.completionHaloElement.style.opacity = "0"; 1472 this.completionHaloElement.style.transform = ""; 1473 } 1474 1475 if (this.progressVisualElement) { 1476 this.progressVisualElement.style.transform = ""; 1477 } 1478 } 1479 1480 stopPanelAnimations() { 1481 this.panelAnimation?.stop?.(); 1482 this.panelAnimation = null; 1483 } 1484 1485 stopAnimations() { 1486 this.stopPanelAnimations(); 1487 this.completionAnimations.forEach((animation) => animation.stop?.()); 1488 this.stopSuccessAnimations(); 1489 this.completionAnimations = []; 1490 } 1491 1492 announce(message) { 1493 if (this.liveRegionElement) this.liveRegionElement.textContent = message; 1494 } 1495};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.