@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
JavaScript
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