UNPKG

@unicity/design-system

Version:

A comprehensive React component library built on Material-UI with advanced theming capabilities including neumorphism design support

244 lines 9.26 kB
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { useRef, useEffect, useState, useCallback } from 'react'; import { styled } from '@mui/material/styles'; import { Box, Typography } from '@mui/material'; const OdometerContainer = styled(Box)({ display: 'inline-flex', alignItems: 'center', fontFamily: "'Courier New', monospace", fontWeight: 'bold', lineHeight: 1, overflow: 'hidden', position: 'relative', }); const DigitContainer = styled(Box)({ display: 'inline-block', position: 'relative', overflow: 'hidden', width: '0.6em', height: '1em', // Full height to show one digit properly textAlign: 'center', }); const DigitRibbon = styled(Box, { shouldForwardProp: (prop) => prop !== 'offset', })(({ offset }) => ({ position: 'absolute', top: 0, left: 0, width: '100%', height: '10em', // 10 digits × 1em each = 10em total height transform: `translateY(${offset}em)`, transition: 'transform 0.5s ease-out', })); const Digit = styled(Typography)({ height: '1em', // Each digit is 1em tall display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', fontSize: 'inherit', fontFamily: 'inherit', fontWeight: 'inherit', lineHeight: 1, margin: 0, padding: 0, }); const LiteralSegment = styled(Typography)({ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', height: '1em', fontSize: 'inherit', fontFamily: 'inherit', fontWeight: 'inherit', lineHeight: 1, padding: '0 0.02em', margin: 0, }); // Selectable text overlay for clipboard functionality const SelectableText = styled(Typography)({ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', display: 'flex', alignItems: 'center', color: 'transparent', fontSize: 'inherit', fontFamily: 'inherit', fontWeight: 'inherit', lineHeight: 1, userSelect: 'text', cursor: 'text', zIndex: 1, margin: 0, padding: 0, '&::selection': { backgroundColor: 'rgba(0, 0, 0, 0.1)', color: 'inherit', }, '&::-moz-selection': { backgroundColor: 'rgba(0, 0, 0, 0.1)', color: 'inherit', }, }); const Odometer = ({ value, locale = 'en-US', numberFormatOptions = {}, className, animateOnMount = true, sx, styles = {} }) => { const containerRef = useRef(null); const [currentValue, setCurrentValue] = useState(animateOnMount ? 0 : value); const [isAnimating, setIsAnimating] = useState(false); const [hasAnimated, setHasAnimated] = useState(!animateOnMount); const [isVisible, setIsVisible] = useState(false); // Create NumberFormat instance const numberFormatter = useCallback(() => { return new Intl.NumberFormat(locale, numberFormatOptions); }, [locale, numberFormatOptions]); // Parse formatted number into segments const parseFormattedNumber = useCallback((num) => { const formatter = numberFormatter(); const formattedString = formatter.format(num); const segments = []; let digitCount = 0; let decimalPlaces = 0; let position = 0; for (let i = 0; i < formattedString.length; i++) { const char = formattedString[i]; if (/\d/.test(char)) { segments.push({ type: 'digit', value: char, position: position++ }); digitCount++; } else if (char === '.' || char === ',') { // Determine if this is decimal or group separator based on context const isDecimal = i > 0 && /\d/.test(formattedString[i - 1]) && i < formattedString.length - 1 && /\d/.test(formattedString[i + 1]); if (isDecimal) { segments.push({ type: 'decimal', value: char, position: position++ }); } else { segments.push({ type: 'thousands', value: char, position: position++ }); } } else { // Treat any other character as literal (currency symbols, spaces, etc.) segments.push({ type: 'literal', value: char, position: position++ }); } } // Count decimal places const decimalIndex = segments.findIndex(s => s.type === 'decimal'); if (decimalIndex !== -1) { decimalPlaces = segments.slice(decimalIndex + 1).filter(s => s.type === 'digit').length; } return { segments, digitCount, decimalPlaces }; }, [numberFormatter]); // Format number according to parsed format const formatNumber = useCallback((num, parsedFormat) => { const { segments } = parsedFormat; // Handle negative numbers const isNegative = num < 0; const absNum = Math.abs(num); // Format the number using Intl.NumberFormat const formatter = numberFormatter(); const formattedString = formatter.format(absNum); // Convert to array of characters const chars = formattedString.split(''); // Map characters to segments const result = []; let charIndex = 0; for (const segment of segments) { if (segment.type === 'digit') { result.push(chars[charIndex] || '0'); charIndex++; } else { result.push(segment.value); } } return result; }, [numberFormatter]); // Generate clipboard-friendly text const getClipboardText = useCallback((num) => { const formatter = numberFormatter(); return formatter.format(num); }, [numberFormatter]); // Calculate ribbon offset for a digit const getDigitOffset = useCallback((digit) => { const digitValue = parseInt(digit, 10); // Each digit is 1em tall, so digit 0 is at 0, digit 1 is at -1em, etc. // The ribbon height is 10em (10 digits × 1em each) return -digitValue * 1; }, []); // Animate to new value const animateToValue = useCallback((newValue) => { if (newValue === currentValue) return; setIsAnimating(true); setCurrentValue(newValue); // Reset animation state after transition setTimeout(() => { setIsAnimating(false); }, 500); }, [currentValue]); // Intersection Observer for visibility-based animation useEffect(() => { if (!animateOnMount || hasAnimated) return; const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting && !hasAnimated) { setHasAnimated(true); setIsVisible(true); // Start animation from 0 to target value with a slight delay for better UX setTimeout(() => { animateToValue(value); }, 200); } }); }, { threshold: 0.1, // Trigger when 10% of the element is visible rootMargin: '50px', // Start animation slightly before element is fully visible }); if (containerRef.current) { observer.observe(containerRef.current); } return () => { observer.disconnect(); }; }, [animateOnMount, hasAnimated, value, animateToValue]); // Update value when prop changes (if not animating on mount) useEffect(() => { if (!animateOnMount || hasAnimated) { animateToValue(value); } }, [value, animateToValue, animateOnMount, hasAnimated]); const parsedFormat = parseFormattedNumber(value); const formattedDigits = formatNumber(currentValue, parsedFormat); const clipboardText = getClipboardText(currentValue); return (_jsxs(OdometerContainer, { ref: containerRef, className: className, sx: styles.container || sx, children: [_jsx(SelectableText, { variant: "h3", sx: styles.selectableText, children: clipboardText }), parsedFormat.segments.map((segment, index) => { if (segment.type === 'digit') { const digit = formattedDigits[index]; const offset = getDigitOffset(digit); return (_jsx(DigitContainer, { sx: styles.digitContainer, children: _jsx(DigitRibbon, { offset: offset, sx: styles.digitRibbon, children: Array.from({ length: 10 }, (_, i) => (_jsx(Digit, { variant: "h3", sx: styles.digit, children: i }, i))) }) }, `digit-${index}`)); } else { return (_jsx(LiteralSegment, { variant: "h3", sx: styles.literalSegment, children: segment.value }, `literal-${index}`)); } })] })); }; export { Odometer }; //# sourceMappingURL=Odometer.js.map