diff --git a/src/components/slider/Example.tsx b/src/components/slider/Example.tsx new file mode 100644 index 0000000..7835a5a --- /dev/null +++ b/src/components/slider/Example.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { Slider } from "./Slider"; + +export function SliderExamples() { + const [continuousValue, setContinuousValue] = useState(50); + const [discreteValue, setDiscreteValue] = useState(5); + const [intensityValue, setIntensityValue] = useState(7); + const [weightValue, setWeightValue] = useState(70); + + return ( +
+ {/* Example 1: Basic Continuous Slider */} +
+ +

Selected: {continuousValue.toFixed(1)}

+
+ + {/* Example 2: Discrete Slider */} +
+ +

Selected: {discreteValue}

+
+ + {/* Example 3: Small Size */} +
+ +
+ + {/* Example 4: Large Size */} +
+ +
+
+ ); +} + +/** + * COMMON USE CASES IN PEAKFIT: + * + * 1. Workout Intensity Selection + * - 1-10 scale for perceived exertion + * + * 2. Rep/Set Ranges + * - Set target reps for exercises + * + * 3. Weight Selection + * - Choose working weight for exercises + * + * 4. Rest Time + * - Set rest periods between sets + * + * 5. Progress Tracking + * - Visual representation of goal completion + * + * 6. Nutrition Macros + * - Adjust protein/carb/fat ratios + */ + +export default SliderExamples; diff --git a/src/components/slider/Slider.module.css b/src/components/slider/Slider.module.css new file mode 100644 index 0000000..5c82e7a --- /dev/null +++ b/src/components/slider/Slider.module.css @@ -0,0 +1,262 @@ +/* Slider.module.css - Uses PeakFit Theme System */ + +.container { + width: 100%; + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Safari */ + -khtml-user-select: none; /* Konqueror HTML */ + -moz-user-select: none; /* Old versions of Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Non-prefixed version, currently + supported by Chrome, Edge, Opera and Firefox */ +} + +.label { + display: block; + margin-bottom: 1rem; + color: var(--text-primary); +} + +.sliderWrapper { + display: flex; + align-items: center; + gap: 1rem; +} + +.value { + font-family: var(--font-ui); + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + min-width: 2.5rem; + text-align: center; +} + +.minValue { + text-align: right; +} + +.maxValue { + text-align: left; +} + +/* =============================== + Slider Track + =============================== */ +.sliderTrack { + position: relative; + flex: 1; + background: var(--surface-tertiary); + border: 1px solid var(--border-default); + width: 100px; + border-radius: 100px; + cursor: pointer; + transition: all 0.2s ease; +} + +/* Sizes */ +.sliderTrack.small { + height: 8px; +} + +.sliderTrack.medium { + height: 12px; +} + +.sliderTrack.large { + height: 16px; +} + +.sliderTrack:hover { + background: var(--surface-hover); +} + +.sliderTrack.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.sliderTrack.disabled:hover { + background: var(--surface-tertiary); +} + +/* =============================== + Filled Track + =============================== */ +.sliderFill { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--gradient-primary); + border-radius: 100px; + pointer-events: none; + /* No transition - instant response */ +} + +/* =============================== + Thumb + =============================== */ +.thumb { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + background: var(--surface-primary); + border: 3px solid var(--primary-500); + border-radius: 50%; + box-shadow: var(--shadow-md); + transition: all 0.2s ease; + z-index: 2; +} + +/* Thumb sizes based on track size */ +.small .thumb { + width: 18px; + height: 18px; +} + +.medium .thumb { + width: 22px; + height: 22px; +} + +.large .thumb { + width: 26px; + height: 26px; +} + +.thumb:hover { + transform: translate(-50%, -50%) scale(1.1); + box-shadow: var(--shadow-glow-primary); +} + +/* Disable the width transition while dragging */ +.sliderFill.dragging { + transition: none; +} + +/* Update your existing .thumb.dragging class */ +.thumb.dragging { + transform: translate(-50%, -50%) scale(1.15); + box-shadow: var(--shadow-glow-primary); + /* Override 'transition: all' so 'left' updates instantly, + but keep the smooth scale and glow effect */ + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.disabled .thumb { + cursor: not-allowed; +} + +.disabled .thumb:hover { + transform: translate(-50%, -50%); + box-shadow: var(--shadow-md); +} + +/* =============================== + Tooltip + =============================== */ +.tooltip { + position: absolute; + bottom: calc(100% + 12px); + left: 50%; + transform: translateX(-50%); + padding: 0.375rem 0.75rem; + background: var(--surface-secondary); + border: 1px solid var(--border-strong); + border-radius: 8px; + color: var(--text-primary); + font-family: var(--font-ui); + font-size: 0.875rem; + font-weight: 600; + white-space: nowrap; + box-shadow: var(--shadow-lg); + pointer-events: none; + animation: tooltipFadeIn 0.2s ease-out; + z-index: 10; +} + +.tooltip::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid var(--surface-secondary); +} + +@keyframes tooltipFadeIn { + from { + opacity: 0; + transform: translateX(-50%) translateY(-4px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +/* =============================== + Discrete Steps + =============================== */ +.stepsContainer { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + display: flex; + justify-content: center; + align-items: center; +} + +.stepsSubContainer { + display: flex; + justify-content: space-between; + align-items: center; + width: 98%; + height: 100%; +} + +.stepMarker { + width: 4px; + height: 4px; + background: var(--surface-hover); + border-radius: 50%; + transition: all 0.2s ease; + background: var(--primary-400); +} + +.stepMarker.active { + background: var(--primary-100); +} + +/* Hide step markers on small size for cleaner look */ +.small .stepMarker { + width: 3px; + height: 3px; +} + +/* =============================== + Responsive + =============================== */ +@media (max-width: 640px) { + .sliderWrapper { + gap: 0.75rem; + } + + .value { + font-size: 0.75rem; + min-width: 2rem; + } + + .tooltip { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + } +} diff --git a/src/components/slider/Slider.tsx b/src/components/slider/Slider.tsx new file mode 100644 index 0000000..97884c2 --- /dev/null +++ b/src/components/slider/Slider.tsx @@ -0,0 +1,188 @@ +import React, { useState, useRef, useEffect } from "react"; +import classes from "./Slider.module.css"; + +type SliderSize = "small" | "medium" | "large"; +type SliderType = "continuous" | "discrete"; + +interface SliderProps { + min: number; + max: number; + value: number; + onChange: (value: number) => void; + type?: SliderType; + step?: number; + size?: SliderSize; + label?: string; + disabled?: boolean; + showMinMax?: boolean; + className?: string; +} + +export const Slider: React.FC = ({ + min, + max, + value, + onChange, + type = "continuous", + step = 1, + size = "medium", + label, + disabled = false, + showMinMax = true, + className = "", +}) => { + const [showTooltip, setShowTooltip] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const sliderRef = useRef(null); + + // Calculate percentage from value + const percentage = ((value - min) / (max - min)) * 100; + + // Handle value calculation and update + const updateValue = (clientX: number) => { + if (!sliderRef.current || disabled) return; + + const rect = sliderRef.current.getBoundingClientRect(); + const offsetX = Math.max(0, Math.min(clientX - rect.left, rect.width)); + const percent = offsetX / rect.width; + + let newValue = min + percent * (max - min); + + // Snap to step + const actualStep = type === "discrete" ? step : 1; + newValue = Math.round(newValue / actualStep) * actualStep; + newValue = Math.max(min, Math.min(max, newValue)); + + if (newValue !== value) { + onChange(newValue); + } + }; + + // Mouse handlers + const handleMouseDown = (e: React.MouseEvent) => { + if (disabled) return; + e.preventDefault(); + setIsDragging(true); + updateValue(e.clientX); + }; + + // Touch handlers + const handleTouchStart = (e: React.TouchEvent) => { + if (disabled) return; + setIsDragging(true); + updateValue(e.touches[0].clientX); + }; + + // Effect for drag events + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + updateValue(e.clientX); + }; + + const handleTouchMove = (e: TouchEvent) => { + e.preventDefault(); + updateValue(e.touches[0].clientX); + }; + + const handleEnd = () => { + setIsDragging(false); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleEnd); + document.addEventListener("touchmove", handleTouchMove, { passive: false }); + document.addEventListener("touchend", handleEnd); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleEnd); + document.removeEventListener("touchmove", handleTouchMove); + document.removeEventListener("touchend", handleEnd); + }; + }, [isDragging, value, min, max, step, type, disabled]); + + // Format value for display + const formatValue = (val: number): string => { + return val.toFixed(0); + }; + + // Generate step markers for discrete mode + const generateSteps = () => { + const steps = []; + for (let i = min; i <= max; i += step) { + steps.push({ + value: i, + isActive: i <= value, + }); + } + return steps; + }; + + return ( +
+ {label && } + +
+ {/* Min value */} + {showMinMax && ( + + {formatValue(min)} + + )} + + {/* Slider track */} +
+ {/* Filled track */} +
+ + {/* Discrete step markers */} + {type === "discrete" && ( +
+
+ {generateSteps().map((step, i) => ( +
+ ))} +
+
+ )} + + {/* Thumb */} +
setShowTooltip(true)} + onMouseLeave={() => !isDragging && setShowTooltip(false)} + > + {/* Tooltip */} + {(showTooltip || isDragging) && ( +
{formatValue(value)}
+ )} +
+
+ + {/* Max value */} + {showMinMax && ( + + {formatValue(max)} + + )} +
+
+ ); +};