react-split-flap
Version:
Split-flap display component for React
284 lines (270 loc) • 15 kB
JavaScript
import { jsxs, jsx } from 'react/jsx-runtime';
import React, { useState, useEffect, useCallback, Fragment } from 'react';
function styleInject(css, ref) {
if ( ref === void 0 ) ref = {};
var insertAt = ref.insertAt;
if (typeof document === 'undefined') { return; }
var head = document.head || document.getElementsByTagName('head')[0];
var style = document.createElement('style');
style.type = 'text/css';
if (insertAt === 'top') {
if (head.firstChild) {
head.insertBefore(style, head.firstChild);
} else {
head.appendChild(style);
}
} else {
head.appendChild(style);
}
if (style.styleSheet) {
style.styleSheet.cssText = css;
} else {
style.appendChild(document.createTextNode(css));
}
}
var css_248z = "@keyframes flapDownTop{0%{transform:rotateX(0deg) translateZ(0)}50%,to{transform:rotateX(90deg) translateZ(0)}}@keyframes flapDownBottom{0%,50%{transform:rotateX(90deg) translateZ(0)}90%{transform:rotateX(20deg) translateZ(0)}80%,to{transform:rotateX(0deg) translateZ(0)}}.split-flap-display{align-items:center;display:flex;flex-direction:row;gap:2px;justify-content:center;perspective:1000px}.split-flap-display.words-mode{gap:0}.split-flap-digit{background-color:#182216;border-radius:4px;box-sizing:content-box;color:var(--flap-font-color,#fff);display:inline-block;font-family:Monaco,Menlo,Ubuntu Mono,monospace,sans-serif;font-weight:700;height:1em;line-height:1em;overflow:hidden;position:relative;text-align:center;transform-style:preserve-3d;width:1.1ch}.split-flap-digit[data-mode=alpha]{width:1.7ch}.split-flap-digit[data-mode=words]{min-width:2ch;width:100%}.split-flap-part{align-items:center;animation-fill-mode:forwards;backface-visibility:hidden;background-color:inherit;background:var(--flap-background,linear-gradient(145deg,#1a1a1a 0,#191919 15%));border:1px solid #333;border-radius:inherit;box-shadow:0 1px 3px rgba(0,0,0,.3);box-sizing:border-box;display:flex;height:100%;justify-content:center;position:absolute;transform-origin:center;width:100%;z-index:1}.split-flap-part.animated{animation-duration:.3s;z-index:2}.split-flap-part.top{align-items:flex-start;clip-path:polygon(0 50%,100% 50%,100% 0,0 0);padding-bottom:.05em}.split-flap-part.top.animated{opacity:.4;transform:rotateX(50deg)}.split-flap-part.top.animated.final{animation-name:flapDownTop;animation-timing-function:ease-in;opacity:1}.split-flap-part.bottom{align-items:flex-end;clip-path:polygon(0 100%,100% 100%,100% 50%,0 50%)}.split-flap-part.bottom.animated.final{animation-name:flapDownBottom;animation-timing-function:ease-out}.split-flap-char{display:block;font-size:inherit;line-height:1}.split-flap-hinge{background:#000;box-shadow:0 0 2px rgba(0,0,0,.3);box-sizing:border-box;height:1px;left:0;position:absolute;right:0;top:50%;transform:translateY(-50%);z-index:3}.split-flap-display.light .split-flap-digit{background:#f0f0f0;color:var(--flap-font-color,#333)}.split-flap-display.light .split-flap-part{background:var(--flap-background,linear-gradient(145deg,#f8f8f8,#e8e8e8));border:1px solid #ccc;box-shadow:inset 2px 2px 4px hsla(0,0%,100%,.5),inset -2px -2px 10px rgba(0,0,0,.1),0 1px 3px rgba(0,0,0,.2)}.split-flap-display.light .split-flap-hinge{background:linear-gradient(90deg,#b9b9b9,#cfcfcf 50%,#b9b9b9);box-shadow:0 0 2px rgba(0,0,0,.2)}.split-flap-display.dark .split-flap-digit{background:#1a1a1a;color:var(--flap-font-color,#fff)}.split-flap-display.dark .split-flap-part{background:var(--flap-background,linear-gradient(184deg,#212121 0,#191919 25%));border:1px solid #222}.split-flap-display.dark .split-flap-hinge{background:linear-gradient(90deg,#333,#222 50%,#333)}.split-flap-display.small{font-size:20px}.split-flap-display.medium{font-size:36px}.split-flap-display.large{font-size:54px}.split-flap-display.xlarge{font-size:84px}.split-flap-display.long-flap-mode{font-size:inherit}.split-flap-digit:before{background:linear-gradient(135deg,hsla(0,0%,100%,.1),hsla(0,0%,100%,.05) 25%,hsla(0,0%,100%,0) 50%,rgba(0,0,0,.05) 75%,rgba(0,0,0,.1));border-radius:inherit;bottom:0;content:\"\";left:0;pointer-events:none;position:absolute;right:0;top:0;z-index:4}@media (prefers-reduced-motion:reduce){.split-flap-part.animated{animation-duration:50ms}.split-flap-part.top.animated{opacity:1;transform:none}}@media (prefers-contrast:high){.split-flap-digit{border:2px solid}.split-flap-display.light .split-flap-digit{background:#fff;color:#000}.split-flap-display.dark .split-flap-digit{background:#000;color:#fff}}.split-flap-display.words-mode .split-flap-digit{border-radius:0;margin:0}.split-flap-display.words-mode .split-flap-digit:first-child{border-bottom-left-radius:4px;border-top-left-radius:4px}.split-flap-display.words-mode .split-flap-digit:last-child{border-bottom-right-radius:4px;border-top-right-radius:4px}.split-flap-display.words-mode .split-flap-digit:only-child{border-radius:4px}.split-flap-display:focus-within .split-flap-digit{box-shadow:0 0 0 2px rgba(59,130,246,.5)}.split-flap-part:not(.animated){transition:background-color .2s ease,border-color .2s ease}.split-flap-display.long-flap-mode .split-flap-digit{height:var(--digit-height,auto);min-height:var(--digit-height,auto);min-width:var(--digit-width,auto);width:var(--digit-width,auto)}.split-flap-display.long-flap-mode .split-flap-char{align-items:center;display:flex;height:100%;justify-content:center;width:100%}";
styleInject(css_248z);
const Flap = ({ bottom, animated, final, hinge, children }) => {
const classes = ['split-flap-part', bottom ? 'bottom' : 'top', animated ? 'animated' : '', final ? 'final' : '']
.filter(Boolean)
.join(' ');
return (jsxs("div", { className: classes, children: [jsx("span", { className: "split-flap-char", children: children }), hinge && jsx("div", { className: "split-flap-hinge", "data-kind": "hinge" })] }));
};
const FlapDigit = ({ value, prevValue, final, mode, hinge, digitWidth }) => {
const digitStyle = digitWidth ? { width: `${digitWidth}px` } : {};
return (jsxs("div", { className: "split-flap-digit", "data-kind": "digit", "data-mode": mode, style: digitStyle, children: [jsx(Flap, { hinge: hinge, children: value }), jsx(Flap, { bottom: true, hinge: hinge, children: prevValue }), jsx(Flap, { animated: true, final: final, hinge: hinge, children: prevValue }, `top-${(prevValue === null || prevValue === void 0 ? void 0 : prevValue.key) || String(prevValue)}`), final && (jsx(Flap, { bottom: true, animated: true, final: true, hinge: hinge, children: value }, `bottom-${(value === null || value === void 0 ? void 0 : value.key) || String(value)}`))] }));
};
const FlapStack = ({ stack, value, timing, mode, hinge, digitWidth }) => {
const [cursor, setCursor] = useState({
current: -1,
previous: -1,
target: 0,
});
// Reset cursor when stack changes
useEffect(() => {
setCursor({
current: -1,
previous: -1,
target: 0,
});
}, [stack]);
// Animation sequence when value changes
useEffect(() => {
// Validate index and set target
const target = Math.max(0, Math.min(value, stack.length - 1));
const currentStack = stack;
let { current } = cursor;
// Continue animation if not at target
if (current !== target) {
const timer = setInterval(() => {
setCursor((prevCursor) => {
const newPrevious = prevCursor.current;
const newCurrent = prevCursor.current >= currentStack.length - 1 ? 0 : prevCursor.current + 1;
if (newCurrent === target) {
clearInterval(timer);
}
return {
current: newCurrent,
previous: newPrevious,
target,
};
});
}, timing);
return () => {
clearInterval(timer);
};
}
}, [stack, value, timing]);
const { current, previous, target } = cursor;
return (jsx(FlapDigit, { value: stack[current] || '', prevValue: stack[previous] || '', final: current === target, mode: mode, hinge: hinge, digitWidth: digitWidth }));
};
const Presets = {
NUM: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
ALPHANUM: [
' ',
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
],
SPECIAL: [
' ',
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'!',
'@',
'#',
'$',
'%',
'^',
'&',
'*',
'(',
')',
'.',
',',
'?',
'-',
'_',
],
};
// Main display component
const SplitFlap = ({ value, chars = Presets.NUM, length, padChar = ' ', padMode = 'auto', digitWidth, timing = 60, hinge = true, theme = 'default', size = 'medium', className = '', style, background, fontColor, render, }) => {
const [displayValue, setDisplayValue] = useState('');
const [mode, setMode] = useState('num');
// Determine mode based on chars content
useEffect(() => {
// Check if chars contains only numbers
const isNumeric = chars.every((char) => /^[\s0-9]$/.test(char));
if (isNumeric) {
setMode('num');
}
else {
setMode('alpha');
}
}, [chars]);
// Process value with padding
useEffect(() => {
let processedValue = value;
// Handle padding
if (processedValue.length < length) {
const padCount = length - processedValue.length;
const padding = padChar.repeat(padCount);
const padEnd = padMode === 'auto' ? /^[0-9.,+-]*$/.test(processedValue) : padMode === 'end';
processedValue = padEnd ? padding + processedValue : processedValue + padding;
}
else if (processedValue.length > length) {
processedValue = processedValue.substring(0, length);
}
// Convert to uppercase for string chars
processedValue = processedValue.toUpperCase();
setDisplayValue(processedValue);
}, [value, length, padChar, padMode, mode]);
// Helper function to find index of char in stack
const findCharIndexInStack = (char) => {
// First try exact match
const index = chars.findIndex((item) => item === char);
if (index !== -1) {
return { index, stack: chars };
}
// If no exact match, create extended stack with the missing character
// This will cause a full rotation through all chars before showing the fallback
const extendedStack = [...chars, char];
return { index: extendedStack.length - 1, stack: extendedStack };
};
const renderDigits = useCallback(() => {
const digits = [];
// When length=1, display the entire value as a single flap
if (length === 1) {
// Use displayValue which already includes padding and case conversion
const { index: stackIndex, stack } = findCharIndexInStack(displayValue);
digits.push(jsx(FlapStack, { stack: stack, value: stackIndex, mode: "words", timing: timing, hinge: hinge, digitWidth: digitWidth }, 0));
}
else {
// When length>1, display each character separately
for (let i = 0; i < length; i++) {
const char = displayValue[i] || padChar;
const { index: stackIndex, stack } = findCharIndexInStack(char);
digits.push(jsx(FlapStack, { stack: stack, value: stackIndex, mode: mode, timing: timing, hinge: hinge, digitWidth: digitWidth }, i));
}
}
return digits;
}, [displayValue, chars, mode, length, padChar, timing, hinge, digitWidth, value]);
const displayClasses = [
'split-flap-display',
theme !== 'default' ? theme : '',
size, // Always include the size class
length === 1 ? 'words-mode' : '',
className,
]
.filter(Boolean)
.join(' ');
// Combine styles with custom background and font color
const combinedStyle = Object.assign(Object.assign(Object.assign({}, style), (background && { '--flap-background': background })), (fontColor && { '--flap-font-color': fontColor }));
const content = (jsx("div", { className: displayClasses, style: combinedStyle, "aria-hidden": "true", "aria-label": value, children: renderDigits() }));
return render ? render(content) : content;
};
const LongFlap = ({ flaps, displayId, digitWidth, digitHeight = 50, timing = 60, hinge = true, theme = 'default', size = 'medium', className = '', style, background, fontColor, render, }) => {
// Memoize the stack to maintain stable references
const flapStack = React.useMemo(() => flaps.map((f) => jsx(Fragment, { children: f.component }, f.id)), [flaps]);
// Memoize the current index to maintain stable reference
const currentIndex = React.useMemo(() => {
const flapIndex = flaps.findIndex((f) => f.id === displayId);
return flapIndex !== -1 ? flapIndex : 0;
}, [flaps, displayId]);
const displayClasses = ['split-flap-display', theme !== 'default' ? theme : '', size, 'long-flap-mode', className]
.filter(Boolean)
.join(' ');
// Add custom styles for LongFlap to ensure proper display
const longFlapStyle = Object.assign(Object.assign(Object.assign(Object.assign({}, style), { '--digit-width': digitWidth ? `${digitWidth}px` : 'auto', '--digit-height': digitHeight ? `${digitHeight}px` : 'auto' }), (background && { '--flap-background': background })), (fontColor && { '--flap-font-color': fontColor }));
const content = (jsx("div", { className: displayClasses, style: longFlapStyle, "aria-hidden": "true", children: jsx(FlapStack, { stack: flapStack, value: currentIndex, timing: timing, mode: "words", hinge: hinge, digitWidth: digitWidth }) }));
return render ? render(content) : content;
};
export { Flap, FlapDigit, FlapStack, LongFlap, Presets, SplitFlap, SplitFlap as default };
//# sourceMappingURL=index.es.js.map