mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +03:00
- Add skip-to-content link for keyboard navigation (WCAG 2.4.1) - Add ARIA labels to download buttons and periodic table elements - Convert periodic table from divs to semantic buttons with keyboard support - Add focus-visible styles to all interactive elements - Implement WAI-ARIA tabs pattern for pattern categories with arrow key nav - Add global prefers-reduced-motion support in CSS and JavaScript - Fix touch gesture trapping in split-compare (allows vertical scroll) - Add error states with retry button for API failures - Add underlines to links for non-color identification (WCAG 1.4.1) - Respect reduced motion preference in Lenis smooth scroll and hero canvas 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
188 lines
5.6 KiB
JavaScript
188 lines
5.6 KiB
JavaScript
// ============================================
|
|
// SPLIT COMPARE - Reusable before/after split-screen effect
|
|
// ============================================
|
|
|
|
/**
|
|
* Initialize split comparison effect on a container
|
|
* @param {HTMLElement} container - The container element with .split-container inside
|
|
* @param {Object} options - Configuration options
|
|
*/
|
|
export function initSplitCompare(container, options = {}) {
|
|
const {
|
|
defaultPosition = 70,
|
|
skewOffset = 8,
|
|
minPosition = 5,
|
|
maxPosition = 95,
|
|
lerpSpeed = 0.15,
|
|
animationThreshold = 40, // Re-trigger animations when crossing this threshold
|
|
onCrossThreshold = null // Callback when crossing threshold toward "after" side
|
|
} = options;
|
|
|
|
const splitContainer = container.querySelector('.split-container');
|
|
const splitAfter = container.querySelector('.split-after');
|
|
const splitDivider = container.querySelector('.split-divider');
|
|
|
|
if (!splitContainer || !splitAfter || !splitDivider) return null;
|
|
|
|
let isHovering = false;
|
|
let currentX = defaultPosition;
|
|
let targetX = defaultPosition;
|
|
let animationId = null;
|
|
let wasAboveThreshold = defaultPosition > animationThreshold;
|
|
|
|
function updateSplit(percent) {
|
|
const clampedX = Math.max(minPosition, Math.min(maxPosition, percent));
|
|
|
|
// Check if we crossed the threshold toward the "after" side (moving left)
|
|
const isAboveThreshold = clampedX > animationThreshold;
|
|
if (wasAboveThreshold && !isAboveThreshold) {
|
|
// Crossed threshold - re-trigger animations
|
|
retriggerAnimations();
|
|
if (onCrossThreshold) onCrossThreshold(clampedX);
|
|
}
|
|
wasAboveThreshold = isAboveThreshold;
|
|
|
|
// Angled clip-path matching divider's skewX(-10deg)
|
|
splitAfter.style.clipPath = `polygon(${clampedX + skewOffset}% 0%, 100% 0%, 100% 100%, ${clampedX - skewOffset}% 100%)`;
|
|
splitDivider.style.left = `${clampedX}%`;
|
|
}
|
|
|
|
function retriggerAnimations() {
|
|
// Find all elements with animations in the "after" content and re-trigger them
|
|
const afterContent = splitAfter.querySelector('.split-content');
|
|
if (afterContent) {
|
|
// Clone and replace to restart all CSS animations
|
|
const clone = afterContent.cloneNode(true);
|
|
afterContent.parentNode.replaceChild(clone, afterContent);
|
|
}
|
|
}
|
|
|
|
function animate() {
|
|
const diff = targetX - currentX;
|
|
if (Math.abs(diff) > 0.1) {
|
|
currentX += diff * lerpSpeed;
|
|
updateSplit(currentX);
|
|
animationId = requestAnimationFrame(animate);
|
|
} else {
|
|
currentX = targetX;
|
|
updateSplit(currentX);
|
|
animationId = null;
|
|
}
|
|
}
|
|
|
|
function startAnimation() {
|
|
if (!animationId) {
|
|
animationId = requestAnimationFrame(animate);
|
|
}
|
|
}
|
|
|
|
function handleMouseEnter() {
|
|
isHovering = true;
|
|
}
|
|
|
|
function handleMouseLeave() {
|
|
isHovering = false;
|
|
targetX = defaultPosition;
|
|
startAnimation();
|
|
}
|
|
|
|
function handleMouseMove(e) {
|
|
if (isHovering) {
|
|
const rect = splitContainer.getBoundingClientRect();
|
|
targetX = ((e.clientX - rect.left) / rect.width) * 100;
|
|
startAnimation();
|
|
}
|
|
}
|
|
|
|
let touchStartX = 0;
|
|
let touchStartY = 0;
|
|
let isDragging = false;
|
|
const DRAG_THRESHOLD = 10; // Minimum horizontal movement to start dragging
|
|
|
|
function handleTouchStart(e) {
|
|
const touch = e.touches[0];
|
|
touchStartX = touch.clientX;
|
|
touchStartY = touch.clientY;
|
|
isDragging = false;
|
|
isHovering = true;
|
|
}
|
|
|
|
function handleTouchEnd() {
|
|
isHovering = false;
|
|
isDragging = false;
|
|
targetX = defaultPosition;
|
|
startAnimation();
|
|
}
|
|
|
|
function handleTouchMove(e) {
|
|
const touch = e.touches[0];
|
|
const deltaX = Math.abs(touch.clientX - touchStartX);
|
|
const deltaY = Math.abs(touch.clientY - touchStartY);
|
|
|
|
// Only start dragging if horizontal movement is greater than vertical
|
|
// This allows vertical scrolling to pass through
|
|
if (!isDragging) {
|
|
if (deltaX > DRAG_THRESHOLD && deltaX > deltaY) {
|
|
isDragging = true;
|
|
} else if (deltaY > DRAG_THRESHOLD) {
|
|
// User is scrolling vertically, don't interfere
|
|
return;
|
|
} else {
|
|
// Not enough movement yet
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Only prevent default when actively dragging horizontally
|
|
if (isDragging) {
|
|
e.preventDefault();
|
|
const rect = splitContainer.getBoundingClientRect();
|
|
targetX = ((touch.clientX - rect.left) / rect.width) * 100;
|
|
startAnimation();
|
|
}
|
|
}
|
|
|
|
// Attach listeners
|
|
splitContainer.addEventListener('mouseenter', handleMouseEnter);
|
|
splitContainer.addEventListener('mouseleave', handleMouseLeave);
|
|
splitContainer.addEventListener('mousemove', handleMouseMove);
|
|
splitContainer.addEventListener('touchstart', handleTouchStart);
|
|
splitContainer.addEventListener('touchend', handleTouchEnd);
|
|
splitContainer.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
|
|
// Initialize
|
|
updateSplit(defaultPosition);
|
|
|
|
// Return cleanup function
|
|
return {
|
|
destroy() {
|
|
splitContainer.removeEventListener('mouseenter', handleMouseEnter);
|
|
splitContainer.removeEventListener('mouseleave', handleMouseLeave);
|
|
splitContainer.removeEventListener('mousemove', handleMouseMove);
|
|
splitContainer.removeEventListener('touchstart', handleTouchStart);
|
|
splitContainer.removeEventListener('touchend', handleTouchEnd);
|
|
splitContainer.removeEventListener('touchmove', handleTouchMove);
|
|
if (animationId) cancelAnimationFrame(animationId);
|
|
},
|
|
setPosition(percent) {
|
|
targetX = percent;
|
|
startAnimation();
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Initialize all split comparisons on the page
|
|
*/
|
|
export function initAllSplitCompare(selector = '.split-comparison', options = {}) {
|
|
const containers = document.querySelectorAll(selector);
|
|
const instances = [];
|
|
|
|
containers.forEach(container => {
|
|
const instance = initSplitCompare(container, options);
|
|
if (instance) instances.push(instance);
|
|
});
|
|
|
|
return instances;
|
|
}
|