UNPKG

react-split-flap

Version:
497 lines (481 loc) 27.1 kB
import { jsx, jsxs, Fragment } from 'react/jsx-runtime'; import React, { forwardRef, useState, useRef, useMemo, useLayoutEffect, useEffect, Fragment as Fragment$1 } from 'react'; import { unstable_batchedUpdates } from 'react-dom'; 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 flapDownTopA{0%{transform:rotateX(0deg) translateZ(0)}50%,to{transform:rotateX(90deg) translateZ(0)}}@keyframes flapDownTopB{0%{transform:rotateX(0deg) translateZ(0)}50%,to{transform:rotateX(90deg) translateZ(0)}}@keyframes flapDownBottomA{0%,50%{transform:rotateX(90deg) translateZ(0)}80%{transform:rotateX(0deg) translateZ(0)}90%{transform:rotateX(20deg) translateZ(0)}to{transform:rotateX(0deg) translateZ(0)}}@keyframes flapDownBottomB{0%,50%{transform:rotateX(90deg) translateZ(0)}80%{transform:rotateX(0deg) translateZ(0)}90%{transform:rotateX(20deg) translateZ(0)}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);contain:layout style paint;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;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;background-color:inherit;background:var(--flap-background,linear-gradient(145deg,#1a1a1a 0,#191919 15%));background-size:100% 200%;border:1px solid #333;border-radius:inherit;box-shadow:0 1px 3px rgba(0,0,0,.3);box-sizing:border-box;display:flex;height:50%;justify-content:center;overflow:hidden;position:absolute;width:100%;z-index:1}.split-flap-part.animated{animation-duration:var(--flap-animation-duration,.3s);z-index:2}.split-flap-part.animated.idle{visibility:hidden}.split-flap-part.top{background-position:top;border-bottom-left-radius:0;border-bottom-right-radius:0;top:0;transform-origin:center bottom}.split-flap-part.top.animated{opacity:.4;transform:rotateX(50deg)}.split-flap-part.top.animated.final{animation-timing-function:ease-in;opacity:1}.split-flap-part.top.animated.final.phase-a{animation-name:flapDownTopA}.split-flap-part.top.animated.final.phase-b{animation-name:flapDownTopB}.split-flap-part.bottom{background-position:bottom;border-top-left-radius:0;border-top-right-radius:0;bottom:0;transform-origin:center top}.split-flap-part.bottom.animated.final{animation-timing-function:ease-out}.split-flap-part.bottom.animated.final.phase-a{animation-name:flapDownBottomA}.split-flap-part.bottom.animated.final.phase-b{animation-name:flapDownBottomB}.split-flap-part.bottom.animated:not(.final){visibility:hidden}.split-flap-char{align-items:center;display:flex;font-size:inherit;height:calc(200% + 4px);justify-content:center;left:0;line-height:1;position:absolute;width:100%}.split-flap-part.top .split-flap-char{top:-1px}.split-flap-part.bottom .split-flap-char{bottom:-1px}.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}.split-flap-display.performance-mode{--flap-animation-duration:160ms}.split-flap-display.performance-mode .split-flap-digit{contain:strict}.split-flap-display.performance-mode .split-flap-hinge,.split-flap-display.performance-mode .split-flap-part{box-shadow:none}.split-flap-display.performance-mode .split-flap-digit:before{content:none}@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:calc(200% + 4px);justify-content:center;width:100%}"; styleInject(css_248z); const Flap = forwardRef(({ bottom, animated, final, phase = 0, idle, children }, ref) => { const classes = [ 'split-flap-part', bottom ? 'bottom' : 'top', animated ? 'animated' : '', final ? 'final' : '', idle ? 'idle' : '', animated && final ? `phase-${phase === 0 ? 'a' : 'b'}` : '', ] .filter(Boolean) .join(' '); return (jsx("div", { ref: ref, className: classes, children: jsx("span", { className: "split-flap-char", children: children }) })); }); Flap.displayName = 'Flap'; // Shared rAF-driven clock so all digits tick in the same frame, // letting React batch every flip into a single commit const subscribers = new Set(); let rafId = null; let timerId = null; // rAF stops in hidden tabs; fall back to a coarse timer so displays // still reach their target value while backgrounded const HIDDEN_TICK = 250; const schedule = () => { if (subscribers.size === 0 || rafId !== null || timerId !== null) return; if (typeof document !== 'undefined' && document.hidden) { timerId = setTimeout(() => { timerId = null; loop(performance.now()); }, HIDDEN_TICK); } else { rafId = requestAnimationFrame(loop); } }; const loop = (now) => { rafId = null; unstable_batchedUpdates(() => { subscribers.forEach((sub) => { if (now >= sub.next) { sub.next = now + sub.interval; sub.callback(); } }); }); schedule(); }; const subscribeTick = (callback, interval) => { const sub = { callback, interval, next: performance.now() + interval }; subscribers.add(sub); schedule(); return () => { subscribers.delete(sub); if (subscribers.size === 0) { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } if (timerId !== null) { clearTimeout(timerId); timerId = null; } } }; }; const FlapDigit = ({ value, prevValue, final, revision, mode, hinge, digitWidth }) => { const digitStyle = digitWidth ? { width: `${digitWidth}px` } : {}; const hasTransition = revision > 0 && value !== prevValue; const shouldAnimate = final && hasTransition; const phase = (revision % 2); return (jsxs("div", { className: "split-flap-digit", "data-kind": "digit", "data-mode": mode, style: digitStyle, children: [jsx(Flap, { children: value }), jsx(Flap, { bottom: true, children: prevValue }), jsx(Flap, { animated: true, final: shouldAnimate, phase: phase, idle: !hasTransition, children: prevValue }), jsx(Flap, { bottom: true, animated: true, final: shouldAnimate, phase: phase, idle: !hasTransition, children: value }), hinge && jsx("div", { className: "split-flap-hinge", "data-kind": "hinge" })] })); }; var FlapDigit$1 = React.memo(FlapDigit); const STRUCTURE_SEPARATOR = '\u0001'; const TARGET_SEPARATOR = '\u0002'; const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; const createCursor = (digit, animate) => ({ current: animate ? -1 : digit.value, previous: animate ? -1 : digit.value, target: digit.value, stackKey: digit.stackKey, revision: 0, settleAt: null, }); // Character displays share one cursor array and one ticker subscription. A // 400-digit board therefore performs at most one React state update per tick, // while memoized FlapDigit children only render when their own cursor changes. const FlapBoard = ({ digits, timing, duration, mode, hinge, digitWidth, animateOnMount = true, }) => { const [cursors, setCursors] = useState(() => digits.map((digit) => createCursor(digit, animateOnMount))); const cursorRef = useRef(cursors); const digitsRef = useRef(digits); digitsRef.current = digits; const structureKey = useMemo(() => digits.map((digit) => digit.stackKey).join(STRUCTURE_SEPARATOR), [digits]); const targetsKey = useMemo(() => digits.map((digit) => digit.value).join(TARGET_SEPARATOR), [digits]); // Reconcile added digits and physical stack changes before paint. Existing // cursors keep their position so a new target can continue an in-flight roll. useIsomorphicLayoutEffect(() => { const currentDigits = digitsRef.current; const previous = cursorRef.current; let changed = previous.length !== currentDigits.length; const next = currentDigits.map((digit, index) => { const cursor = previous[index]; if (!cursor) { changed = true; return createCursor(digit, animateOnMount); } if (cursor.stackKey !== digit.stackKey) { changed = true; return createCursor(digit, true); } return cursor; }); if (changed) { cursorRef.current = next; setCursors(next); } }, [animateOnMount, digits.length, structureKey]); useEffect(() => { const hasPendingDigit = digitsRef.current.some((digit, index) => { const cursor = cursorRef.current[index]; return !cursor || cursor.stackKey !== digit.stackKey || cursor.current !== digit.value; }); if (!hasPendingDigit) return; let unsubscribe = () => { }; unsubscribe = subscribeTick(() => { const currentDigits = digitsRef.current; const previous = cursorRef.current; let changed = previous.length !== currentDigits.length; let hasPendingAfterTick = false; const next = currentDigits.map((digit, index) => { let cursor = previous[index]; if (!cursor) { cursor = createCursor(digit, animateOnMount); changed = true; } else if (cursor.stackKey !== digit.stackKey) { cursor = createCursor(digit, true); changed = true; } if (cursor.current === digit.value) { if (cursor.target === digit.value) return cursor; changed = true; return Object.assign(Object.assign({}, cursor), { target: digit.value }); } const newCurrent = cursor.current >= digit.stack.length - 1 ? 0 : cursor.current + 1; if (newCurrent !== digit.value) hasPendingAfterTick = true; changed = true; return { current: newCurrent, previous: cursor.current, target: digit.value, stackKey: digit.stackKey, revision: cursor.revision + 1, settleAt: newCurrent === digit.value ? performance.now() + duration : null, }; }); if (changed) { cursorRef.current = next; setCursors(next); } if (!hasPendingAfterTick) unsubscribe(); }, timing); return unsubscribe; }, [animateOnMount, duration, structureKey, targetsKey, timing]); const settleKey = useMemo(() => cursors.map((cursor) => { var _a; return (_a = cursor.settleAt) !== null && _a !== void 0 ? _a : ''; }).join(TARGET_SEPARATOR), [cursors]); // Once the compositor animation has finished, promote the current face to // the static bottom layer. This prevents Safari from briefly revealing the // previous transition's stale underlay while preparing the next flip. useEffect(() => { const deadlines = cursors .map((cursor) => cursor.settleAt) .filter((deadline) => deadline !== null); if (deadlines.length === 0) return; const earliest = Math.min(...deadlines); const timer = setTimeout(() => { const previous = cursorRef.current; let changed = false; const next = previous.map((cursor) => { if (cursor.settleAt === null || cursor.settleAt > earliest + 1) return cursor; changed = true; return Object.assign(Object.assign({}, cursor), { previous: cursor.current, settleAt: null }); }); if (changed) { cursorRef.current = next; setCursors(next); } }, Math.max(0, Math.ceil(earliest - performance.now()) + 1)); return () => clearTimeout(timer); }, [cursors, settleKey]); return (jsx(Fragment, { children: digits.map((digit, index) => { const storedCursor = cursors[index]; const cursor = (storedCursor === null || storedCursor === void 0 ? void 0 : storedCursor.stackKey) === digit.stackKey ? storedCursor : createCursor(digit, storedCursor ? true : animateOnMount); return (jsx(FlapDigit$1, { value: digit.stack[cursor.current] || '', prevValue: digit.stack[cursor.previous] || '', final: cursor.current === digit.value, revision: cursor.revision, mode: mode, hinge: hinge, digitWidth: digitWidth }, index)); }) })); }; const FlapStack = ({ stack, stackKey, value, timing, duration, mode, hinge, digitWidth, animateOnMount = true, }) => { const stackLength = stack.length; const [cursor, setCursor] = useState(() => { if (animateOnMount) { return { current: -1, previous: -1, target: 0, revision: 0 }; } const target = Math.max(0, Math.min(value, stackLength - 1)); return { current: target, previous: target, target, revision: 0 }; }); // Reset cursor when the flap set changes (identity via stackKey, not array reference) const mountedRef = useRef(false); useEffect(() => { if (!mountedRef.current) { mountedRef.current = true; return; } setCursor({ current: -1, previous: -1, target: 0, revision: 0 }); }, [stackKey]); // Animation sequence when value changes useEffect(() => { const target = Math.max(0, Math.min(value, stackLength - 1)); const unsubscribe = subscribeTick(() => { setCursor((prevCursor) => { if (prevCursor.current === target) { unsubscribe(); return prevCursor.target === target ? prevCursor : Object.assign(Object.assign({}, prevCursor), { target }); } const newCurrent = prevCursor.current >= stackLength - 1 ? 0 : prevCursor.current + 1; if (newCurrent === target) { unsubscribe(); } return { current: newCurrent, previous: prevCursor.current, target, revision: prevCursor.revision + 1, }; }); }, timing); return unsubscribe; }, [stackKey, stackLength, value, timing]); useEffect(() => { if (cursor.current !== cursor.target || cursor.previous === cursor.current) return; const revision = cursor.revision; const timer = setTimeout(() => { setCursor((current) => current.revision === revision && current.current === current.target ? Object.assign(Object.assign({}, current), { previous: current.current }) : current); }, duration); return () => clearTimeout(timer); }, [cursor.current, cursor.previous, cursor.revision, cursor.target, duration]); const { current, previous, target, revision } = cursor; return (jsx(FlapDigit$1, { value: stack[current] || '', prevValue: stack[previous] || '', final: current === target, revision: revision, mode: mode, hinge: hinge, digitWidth: digitWidth })); }; // Parent displays often update one row or digit at a time. stackKey is the // declared identity of the physical flap set, so comparing it avoids walking // every unchanged digit while still allowing local cursor state to animate. var FlapStack$1 = React.memo(FlapStack, (previous, next) => previous.stackKey === next.stackKey && // Words mode may contain React nodes whose content changes while ids stay stable. (previous.mode !== 'words' || previous.stack === next.stack) && previous.value === next.value && previous.timing === next.timing && previous.duration === next.duration && previous.mode === next.mode && previous.hinge === next.hinge && previous.digitWidth === next.digitWidth && previous.animateOnMount === next.animateOnMount); 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', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '.', ',', '?', '-', '_', ], }; // Joiner unlikely to appear in user-provided chars const KEY_SEPARATOR = '\u0000'; const SplitFlap = ({ value, chars = Presets.NUM, length, mode, padChar = ' ', align, padMode, animateOnMount = true, digitWidth, timing = 60, duration, hinge = true, theme = 'default', size = 'medium', className = '', style, background, fontColor, render, }) => { const effectiveDuration = duration !== null && duration !== void 0 ? duration : (className.split(/\s+/).includes('performance-mode') ? 160 : 300); // Legacy: length === 1 used to mean words mode const wordsMode = mode === 'words' || (mode === undefined && length === 1); const displayLength = length !== null && length !== void 0 ? length : value.length; const charsKey = useMemo(() => chars.join(KEY_SEPARATOR), [chars]); // Only uppercase the value when the char set itself is uppercase, // so lowercase char sets stay matchable const uppercase = useMemo(() => chars.every((char) => char === char.toUpperCase()), [chars]); const digitMode = useMemo(() => (chars.every((char) => /^[\s0-9]$/.test(char)) ? 'num' : 'alpha'), [chars]); const displayValue = useMemo(() => { let processedValue = uppercase ? value.toUpperCase() : value; if (wordsMode) return processedValue; if (processedValue.length < displayLength) { const padding = padChar.repeat(displayLength - processedValue.length); const effectiveAlign = align !== null && align !== void 0 ? align : (padMode === 'start' ? 'left' : padMode === 'end' ? 'right' : 'auto'); const padStart = effectiveAlign === 'right' || (effectiveAlign === 'auto' && /^[0-9.,+-]*$/.test(processedValue)); processedValue = padStart ? padding + processedValue : processedValue + padding; } else if (processedValue.length > displayLength) { processedValue = processedValue.substring(0, displayLength); } return processedValue; }, [value, uppercase, wordsMode, displayLength, padChar, align, padMode]); // Chars missing from the set get an extended stack: a full rotation // through all chars before landing on the fallback const findCharInStack = (char) => { const index = chars.indexOf(char); if (index !== -1) { return { index, stack: chars, key: charsKey }; } const extendedStack = [...chars, char]; return { index: extendedStack.length - 1, stack: extendedStack, key: charsKey + KEY_SEPARATOR + char }; }; const digits = useMemo(() => { if (wordsMode) return []; const result = []; for (let i = 0; i < displayLength; i++) { const char = displayValue[i] || padChar; const { index, stack, key } = findCharInStack(char); result.push({ stack, stackKey: key, value: index }); } return result; }, [chars, charsKey, displayLength, displayValue, padChar, wordsMode]); const renderDigits = () => { if (wordsMode) { const { index, stack, key } = findCharInStack(displayValue); return (jsx(FlapStack$1, { stack: stack, stackKey: key, value: index, mode: "words", timing: timing, duration: effectiveDuration, hinge: hinge, digitWidth: digitWidth, animateOnMount: animateOnMount }, 0)); } return (jsx(FlapBoard, { digits: digits, timing: timing, duration: effectiveDuration, mode: digitMode, hinge: hinge, digitWidth: digitWidth, animateOnMount: animateOnMount })); }; const displayClasses = [ 'split-flap-display', theme !== 'default' ? theme : '', size, wordsMode ? 'words-mode' : '', className, ] .filter(Boolean) .join(' '); const combinedStyle = Object.assign(Object.assign(Object.assign(Object.assign({}, style), { '--flap-animation-duration': `${effectiveDuration}ms` }), (background && { '--flap-background': background })), (fontColor && { '--flap-font-color': fontColor })); // role="img" exposes the label while keeping the flap internals out of the a11y tree const content = (jsx("div", { className: displayClasses, style: combinedStyle, role: "img", "aria-label": value, children: renderDigits() })); return render ? render(content) : content; }; var SplitFlap$1 = React.memo(SplitFlap); const LongFlap = ({ flaps, displayId, animateOnMount = true, digitWidth, digitHeight = 50, timing = 60, duration, hinge = true, theme = 'default', size = 'medium', className = '', style, background, fontColor, render, }) => { const effectiveDuration = duration !== null && duration !== void 0 ? duration : (className.split(/\s+/).includes('performance-mode') ? 160 : 300); const flapStack = useMemo(() => flaps.map((f) => jsx(Fragment$1, { children: f.component }, f.id)), [flaps]); // Cursor identity follows the id list, so inline-defined flaps arrays are safe const stackKey = useMemo(() => flaps.map((f) => String(f.id)).join('\u0000'), [flaps]); const currentIndex = 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(' '); const longFlapStyle = Object.assign(Object.assign(Object.assign(Object.assign({}, style), { '--digit-width': digitWidth ? `${digitWidth}px` : 'auto', '--digit-height': digitHeight ? `${digitHeight}px` : 'auto', '--flap-animation-duration': `${effectiveDuration}ms` }), (background && { '--flap-background': background })), (fontColor && { '--flap-font-color': fontColor })); const content = (jsx("div", { className: displayClasses, style: longFlapStyle, "aria-hidden": "true", children: jsx(FlapStack$1, { stack: flapStack, stackKey: stackKey, value: currentIndex, timing: timing, duration: effectiveDuration, mode: "words", hinge: hinge, digitWidth: digitWidth, animateOnMount: animateOnMount }) })); return render ? render(content) : content; }; export { LongFlap, Presets, SplitFlap$1 as SplitFlap }; //# sourceMappingURL=index.es.js.map