// Roll Mixin - declarative 360° rotation animation on hover // Usage: // // // // Provides roll(), setRollDuration(), etc. import WebComponentBase from '../../components/web-component-base/web-component-base.js'; class RollMixin extends WebComponentBase { [key: string]: any; static get observedAttributes() { return ['roll-duration']; } constructor() { super(); this._isRolling = false; this._rollDuration = 1000; // Default duration in ms this._hoverHandler = null; this._animationTimeout = null; this._cooldownTimeout = null; this._isInCooldown = false; // simple shadow that just renders children "as-is" this.attachShadow({ mode: 'open' }); const shadowRoot = this.shadowRoot; if (!shadowRoot) { throw new Error('Failed to attach shadow root'); } shadowRoot.innerHTML = ` `; } /* -------------------------------------------------- * Public API * -------------------------------------------------- */ roll() { if (this._isRolling || this._isInCooldown) { return; } // Set rolling state BEFORE doing anything else this._isRolling = true; this.emitEvent('roll-started', { duration: this._rollDuration }); // Get the target component (first child element) const targetComponent = this.firstElementChild as HTMLElement | null; if (targetComponent) { this.triggerRollAnimation(targetComponent); } else { this._isRolling = false; } } getRollDuration() { // Source of truth is the attribute, fallback to internal state const attrVal = this.getAttribute('roll-duration'); const num = Number(attrVal); return num > 0 ? num : this._rollDuration; } setRollDuration(duration: string | number) { const parsed = Number(duration) || 1000; // Skip if value hasn't changed if (parsed === this._rollDuration) { return; } this._rollDuration = parsed; // Keep attribute in sync for external observers, avoid loop by checking first const attrValue = this.getAttribute('roll-duration'); if (!attrValue || Number(attrValue) !== parsed) { this.setAttribute('roll-duration', String(parsed)); } this.emitEvent('roll-duration-changed', { duration: this._rollDuration }); } /* -------------------------------------------------- * Private animation method * -------------------------------------------------- */ triggerRollAnimation(targetComponent: HTMLElement) { // Always fetch the latest duration right before animating const currentDuration = this.getRollDuration(); // If Web Animations API is available, prefer it for smoother control if (typeof targetComponent.animate === 'function') { const animation = targetComponent.animate([ { transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' } ], { duration: currentDuration, easing: 'ease-in-out' }); // Clear any existing timeout if (this._animationTimeout) { clearTimeout(this._animationTimeout); } // When animation finishes animation.finished.then(() => { // Ensure final state is reset targetComponent.style.transform = ''; this._animationTimeout = null; // Start cooldown before resetting rolling state this.startCooldown(); this._isRolling = false; this.emitEvent('roll-completed', { duration: currentDuration }); }); // Store a faux timeout ID so logic remains consistent this._animationTimeout = 1; // non-null truthy value return; } // Fallback to CSS transform animation if WAAPI is not available // Store original transform and transition const originalTransform = targetComponent.style.transform || ''; const originalTransition = targetComponent.style.transition || ''; // Apply rotation const newTransform = `${originalTransform} rotate(359deg)`; // 359 to ensure delta const newTransition = `transform ${currentDuration}ms ease-in-out`; targetComponent.style.transition = newTransition; targetComponent.style.transform = newTransform; // Clear any existing timeout if (this._animationTimeout) { clearTimeout(this._animationTimeout); } // Reset after animation this._animationTimeout = setTimeout(() => { targetComponent.style.transform = originalTransform; targetComponent.style.transition = originalTransition; // Start cooldown period BEFORE resetting rolling state this.startCooldown(); // Now reset rolling state this._isRolling = false; this._animationTimeout = null; this.emitEvent('roll-completed', { duration: currentDuration }); }, currentDuration); } /* -------------------------------------------------- * Cooldown management * -------------------------------------------------- */ startCooldown() { this._isInCooldown = true; // Clear any existing cooldown timeout if (this._cooldownTimeout) { clearTimeout(this._cooldownTimeout); } // Cooldown period: 500ms after animation completes this._cooldownTimeout = setTimeout(() => { this._isInCooldown = false; this._cooldownTimeout = null; }, 500); } /* -------------------------------------------------- * Lifecycle * -------------------------------------------------- */ connectedCallback() { super.connectedCallback?.(); // Set initial duration from attribute const duration = this.getAttribute("roll-duration"); if (duration !== null) { this.setRollDuration(duration); } // Set up hover listeners after a short delay to ensure children are ready setTimeout(() => { this.setupHoverListeners(); }, 0); } attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { super.attributeChangedCallback?.(name, oldValue, newValue); if (name === "roll-duration" && newValue) { this.setRollDuration(newValue); } } disconnectedCallback() { super.disconnectedCallback?.(); // Clean up event listeners and timeouts this.cleanupHoverListeners(); if (this._animationTimeout) { clearTimeout(this._animationTimeout); this._animationTimeout = null; } if (this._cooldownTimeout) { clearTimeout(this._cooldownTimeout); this._cooldownTimeout = null; } } /* -------------------------------------------------- * Helper methods * -------------------------------------------------- */ setupHoverListeners() { const targetComponent = this.firstElementChild; if (!targetComponent) { return; } // Remove any existing listeners first this.cleanupHoverListeners(); // Create hover handler this._hoverHandler = (event: MouseEvent) => { // Only trigger if not already rolling, not in cooldown, and correct target if (!this._isRolling && !this._isInCooldown && event.target === targetComponent) { this.roll(); } }; // Add mouse enter listener targetComponent.addEventListener('mouseenter', this._hoverHandler); } cleanupHoverListeners() { const targetComponent = this.firstElementChild; if (targetComponent && this._hoverHandler) { targetComponent.removeEventListener('mouseenter', this._hoverHandler); this._hoverHandler = null; } } } export { RollMixin };