mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 22:49:31 -07:00
1 line
11 KiB
JSON
1 line
11 KiB
JSON
{"name":"ElasticSlider","title":"ElasticSlider","description":"Slider handle stretches elastically then snaps with spring physics.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<template>\n <div :class=\"`flex flex-col items-center justify-center gap-4 w-48 ${className}`\">\n <div\n class=\"flex w-full touch-none select-none items-center justify-center gap-4\"\n :style=\"{\n scale: scale,\n opacity: sliderOpacity\n }\"\n @mouseenter=\"handleMouseEnter\"\n @mouseleave=\"handleMouseLeave\"\n @touchstart=\"handleTouchStart\"\n @touchend=\"handleTouchEnd\"\n >\n <div\n ref=\"leftIconRef\"\n :style=\"{\n transform: `translateX(${leftIconTranslateX}px) scale(${leftIconScale})`\n }\"\n class=\"transition-transform duration-200 ease-out\"\n >\n <slot name=\"left-icon\">\n <component :is=\"leftIcon\" v-if=\"leftIcon && typeof leftIcon === 'object'\" />\n\n <span v-else-if=\"leftIcon\">{{ leftIcon }}</span>\n\n <span v-else>-</span>\n </slot>\n </div>\n\n <div\n ref=\"sliderRef\"\n class=\"relative flex w-full max-w-xs flex-grow cursor-grab touch-none select-none items-center py-4\"\n @pointermove=\"handlePointerMove\"\n @pointerdown=\"handlePointerDown\"\n @pointerup=\"handlePointerUp\"\n >\n <div\n :style=\"{\n transform: `scaleX(${sliderScaleX}) scaleY(${sliderScaleY})`,\n transformOrigin: transformOrigin,\n height: `${sliderHeight}px`,\n marginTop: `${sliderMarginTop}px`,\n marginBottom: `${sliderMarginBottom}px`\n }\"\n class=\"flex flex-grow\"\n >\n <div class=\"relative h-full flex-grow overflow-hidden rounded-full bg-gray-400\">\n <div class=\"absolute h-full bg-[#27FF64] rounded-full\" :style=\"{ width: `${rangePercentage}%` }\" />\n </div>\n </div>\n </div>\n\n <div\n ref=\"rightIconRef\"\n :style=\"{\n transform: `translateX(${rightIconTranslateX}px) scale(${rightIconScale})`\n }\"\n class=\"transition-transform duration-200 ease-out\"\n >\n <slot name=\"right-icon\">\n <component :is=\"rightIcon\" v-if=\"rightIcon && typeof rightIcon === 'object'\" />\n\n <span v-else-if=\"rightIcon\">{{ rightIcon }}</span>\n\n <span v-else>+</span>\n </slot>\n </div>\n </div>\n\n <p class=\"absolute text-gray-400 transform -translate-y-6 font-medium tracking-wide\">{{ Math.round(value) }}</p>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, watch, onMounted, type Component, useTemplateRef } from 'vue';\n\nconst MAX_OVERFLOW = 50;\n\ninterface Props {\n defaultValue?: number;\n startingValue?: number;\n maxValue?: number;\n className?: string;\n isStepped?: boolean;\n stepSize?: number;\n leftIcon?: Component | string;\n rightIcon?: Component | string;\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n defaultValue: 50,\n startingValue: 0,\n maxValue: 100,\n className: '',\n isStepped: false,\n stepSize: 1,\n leftIcon: '-',\n rightIcon: '+'\n});\n\nconst sliderRef = useTemplateRef<HTMLDivElement>('sliderRef');\nconst leftIconRef = useTemplateRef<HTMLDivElement>('leftIconRef');\nconst rightIconRef = useTemplateRef<HTMLDivElement>('rightIconRef');\n\nconst value = ref(props.defaultValue);\nconst region = ref<'left' | 'middle' | 'right'>('middle');\nconst clientX = ref(0);\nconst overflow = ref(0);\nconst scale = ref(1);\nconst leftIconScale = ref(1);\nconst rightIconScale = ref(1);\n\nlet scaleAnimation: number | null = null;\nlet overflowAnimation: number | null = null;\n\nwatch(\n () => props.defaultValue,\n newValue => {\n value.value = newValue;\n }\n);\n\nwatch(clientX, latest => {\n if (sliderRef.value) {\n const { left, right } = sliderRef.value.getBoundingClientRect();\n let newValue: number;\n if (latest < left) {\n region.value = 'left';\n newValue = left - latest;\n } else if (latest > right) {\n region.value = 'right';\n newValue = latest - right;\n } else {\n region.value = 'middle';\n newValue = 0;\n }\n overflow.value = decay(newValue, MAX_OVERFLOW);\n }\n});\n\nconst rangePercentage = computed(() => {\n const totalRange = props.maxValue - props.startingValue;\n if (totalRange === 0) return 0;\n return ((value.value - props.startingValue) / totalRange) * 100;\n});\n\nconst sliderScaleX = computed(() => {\n if (!sliderRef.value) return 1;\n const { width } = sliderRef.value.getBoundingClientRect();\n return 1 + overflow.value / width;\n});\n\nconst sliderScaleY = computed(() => {\n const t = overflow.value / MAX_OVERFLOW;\n return 1 + t * (0.8 - 1);\n});\n\nconst transformOrigin = computed(() => {\n if (!sliderRef.value) return 'center';\n const { left, width } = sliderRef.value.getBoundingClientRect();\n return clientX.value < left + width / 2 ? 'right' : 'left';\n});\n\nconst sliderHeight = computed(() => {\n const t = (scale.value - 1) / (1.2 - 1);\n return 6 + t * (12 - 6);\n});\n\nconst sliderMarginTop = computed(() => {\n const t = (scale.value - 1) / (1.2 - 1);\n return 0 + t * (-3 - 0);\n});\n\nconst sliderMarginBottom = computed(() => {\n const t = (scale.value - 1) / (1.2 - 1);\n return 0 + t * (-3 - 0);\n});\n\nconst sliderOpacity = computed(() => {\n const t = (scale.value - 1) / (1.2 - 1);\n return 0.7 + t * (1 - 0.7);\n});\n\nconst leftIconTranslateX = computed(() => {\n return region.value === 'left' ? -overflow.value / scale.value : 0;\n});\n\nconst rightIconTranslateX = computed(() => {\n return region.value === 'right' ? overflow.value / scale.value : 0;\n});\n\nconst decay = (inputValue: number, max: number): number => {\n if (max === 0) return 0;\n const entry = inputValue / max;\n const sigmoid = 2 * (1 / (1 + Math.exp(-entry)) - 0.5);\n return sigmoid * max;\n};\n\nconst animate = (\n target: { value: number },\n to: number,\n options: { type?: string; bounce?: number; duration?: number } = {}\n) => {\n const { type = 'tween', bounce = 0, duration = 0.3 } = options;\n\n if (type === 'spring') {\n return animateSpring(target, to, bounce, duration);\n } else {\n return animateValue(target, to, duration);\n }\n};\n\nconst animateValue = (target: { value: number }, to: number, duration = 300) => {\n const start = target.value;\n const diff = to - start;\n const startTime = performance.now();\n\n const animateFrame = (currentTime: number) => {\n const elapsed = currentTime - startTime;\n const progress = Math.min(elapsed / duration, 1);\n\n const easeOut = 1 - Math.pow(1 - progress, 3);\n target.value = start + diff * easeOut;\n\n if (progress < 1) {\n return requestAnimationFrame(animateFrame);\n }\n return null;\n };\n\n return requestAnimationFrame(animateFrame);\n};\n\nconst animateSpring = (target: { value: number }, to: number, bounce = 0.5, duration = 600) => {\n const start = target.value;\n const startTime = performance.now();\n\n const mass = 1;\n const stiffness = 170;\n const damping = 26 * (1 - bounce);\n\n const dampingRatio = damping / (2 * Math.sqrt(mass * stiffness));\n const angularFreq = Math.sqrt(stiffness / mass);\n const dampedFreq = angularFreq * Math.sqrt(1 - dampingRatio * dampingRatio);\n\n const animateFrame = (currentTime: number) => {\n const elapsed = currentTime - startTime;\n const t = elapsed / 1000;\n\n let displacement: number;\n\n if (dampingRatio < 1) {\n const envelope = Math.exp(-dampingRatio * angularFreq * t);\n const cos = Math.cos(dampedFreq * t);\n const sin = Math.sin(dampedFreq * t);\n\n displacement = envelope * (cos + ((dampingRatio * angularFreq) / dampedFreq) * sin);\n } else {\n displacement = Math.exp(-angularFreq * t);\n }\n\n const currentValue = to + (start - to) * displacement;\n target.value = currentValue;\n\n const velocity = Math.abs(currentValue - to);\n const isSettled = velocity < 0.01 && elapsed > 100;\n\n if (!isSettled && elapsed < duration * 3) {\n return requestAnimationFrame(animateFrame);\n } else {\n target.value = to;\n return null;\n }\n };\n\n return requestAnimationFrame(animateFrame);\n};\n\nconst animateIconScale = (target: { value: number }, isActive: boolean) => {\n if (isActive) {\n animate(target, 1.4, { duration: 125 });\n setTimeout(() => {\n animate(target, 1, { duration: 125 });\n }, 125);\n } else {\n animate(target, 1, { duration: 250 });\n }\n};\n\nwatch(region, (newRegion, oldRegion) => {\n if (newRegion === 'left' && oldRegion !== 'left') {\n animateIconScale(leftIconScale, true);\n } else if (newRegion === 'right' && oldRegion !== 'right') {\n animateIconScale(rightIconScale, true);\n }\n});\n\nconst handlePointerMove = (e: PointerEvent) => {\n if (e.buttons > 0 && sliderRef.value) {\n const { left, width } = sliderRef.value.getBoundingClientRect();\n\n let newValue = props.startingValue + ((e.clientX - left) / width) * (props.maxValue - props.startingValue);\n\n if (props.isStepped) {\n newValue = Math.round(newValue / props.stepSize) * props.stepSize;\n }\n\n newValue = Math.min(Math.max(newValue, props.startingValue), props.maxValue);\n value.value = newValue;\n\n clientX.value = e.clientX;\n }\n};\n\nconst handlePointerDown = (e: PointerEvent) => {\n handlePointerMove(e);\n (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);\n};\n\nconst handlePointerUp = () => {\n if (overflowAnimation) {\n cancelAnimationFrame(overflowAnimation);\n }\n overflowAnimation = animate(overflow, 0, { type: 'spring', bounce: 0.4, duration: 500 });\n};\n\nconst handleMouseEnter = () => {\n if (scaleAnimation) {\n cancelAnimationFrame(scaleAnimation);\n }\n scaleAnimation = animate(scale, 1.2, { duration: 200 });\n};\n\nconst handleMouseLeave = () => {\n if (scaleAnimation) {\n cancelAnimationFrame(scaleAnimation);\n }\n scaleAnimation = animate(scale, 1, { duration: 200 });\n};\n\nconst handleTouchStart = () => {\n if (scaleAnimation) {\n cancelAnimationFrame(scaleAnimation);\n }\n scaleAnimation = animate(scale, 1.2, { duration: 200 });\n};\n\nconst handleTouchEnd = () => {\n if (scaleAnimation) {\n cancelAnimationFrame(scaleAnimation);\n }\n scaleAnimation = animate(scale, 1, { duration: 200 });\n};\n\nonMounted(() => {\n value.value = props.defaultValue;\n});\n</script>\n","path":"ElasticSlider/ElasticSlider.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[],"devDependencies":[],"categories":["Components"]} |