Refactor RotatingText component props and improve type definitions

This commit is contained in:
David Haz
2025-07-12 16:33:32 +03:00
parent b8bc22797f
commit 50532846ba
2 changed files with 112 additions and 90 deletions

View File

@@ -2,38 +2,47 @@
import { AnimatePresence, Motion, type Target, type Transition, type VariantLabels } from 'motion-v'; import { AnimatePresence, Motion, type Target, type Transition, type VariantLabels } from 'motion-v';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
function cn(...classes: (string | undefined | null | boolean)[]): string { type StaggerFrom = 'first' | 'last' | 'center' | 'random' | number;
return classes.filter(Boolean).join(' '); type SplitBy = 'characters' | 'words' | 'lines';
interface WordElement {
characters: string[];
needsSpace: boolean;
} }
interface RotatingTextProps { interface RotatingTextProps {
texts: string[]; texts: string[];
transition?: Transition; transition?: Transition;
initial?: boolean | Target | VariantLabels; initial?: boolean | Target | VariantLabels;
animate?: any; animate?: Target | VariantLabels;
exit?: Target | VariantLabels; exit?: Target | VariantLabels;
animatePresenceMode?: 'sync' | 'wait'; animatePresenceMode?: 'sync' | 'wait';
animatePresenceInitial?: boolean; animatePresenceInitial?: boolean;
rotationInterval?: number; rotationInterval?: number;
staggerDuration?: number; staggerDuration?: number;
staggerFrom?: 'first' | 'last' | 'center' | 'random' | number; staggerFrom?: StaggerFrom;
loop?: boolean; loop?: boolean;
auto?: boolean; auto?: boolean;
splitBy?: string; splitBy?: SplitBy;
onNext?: (index: number) => void; onNext?: (index: number) => void;
mainClassName?: string; mainClassName?: string;
splitLevelClassName?: string; splitLevelClassName?: string;
elementLevelClassName?: string; elementLevelClassName?: string;
} }
const cn = (...classes: (string | undefined | null | boolean)[]): string => {
return classes.filter(Boolean).join(' ');
};
const props = withDefaults(defineProps<RotatingTextProps>(), { const props = withDefaults(defineProps<RotatingTextProps>(), {
transition: () => ({ transition: () =>
type: 'spring', ({
damping: 25, type: 'spring',
stiffness: 300 damping: 25,
}), stiffness: 300
}) as Transition,
initial: () => ({ y: '100%', opacity: 0 }) as Target, initial: () => ({ y: '100%', opacity: 0 }) as Target,
animate: () => ({ y: 0, opacity: 1 }), animate: () => ({ y: 0, opacity: 1 }) as Target,
exit: () => ({ y: '-120%', opacity: 0 }) as Target, exit: () => ({ y: '-120%', opacity: 0 }) as Target,
animatePresenceMode: 'wait', animatePresenceMode: 'wait',
animatePresenceInitial: false, animatePresenceInitial: false,
@@ -45,105 +54,128 @@ const props = withDefaults(defineProps<RotatingTextProps>(), {
splitBy: 'characters' splitBy: 'characters'
}); });
const currentTextIndex = ref<number>(0); const currentTextIndex = ref(0);
let intervalId: number | null = null; let intervalId: ReturnType<typeof setInterval> | null = null;
const splitIntoCharacters = (text: string): string[] => { const splitIntoCharacters = (text: string): string[] => {
if (typeof Intl !== 'undefined' && Intl.Segmenter) { if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' }); // eslint-disable-next-line @typescript-eslint/no-explicit-any
return Array.from(segmenter.segment(text), segment => segment.segment); const segmenter = new (Intl as any).Segmenter('en', { granularity: 'grapheme' });
return [...segmenter.segment(text)].map(({ segment }) => segment);
} }
return Array.from(text);
return [...text];
}; };
const elements = computed(() => { const elements = computed((): WordElement[] => {
const currentText: string = props.texts[currentTextIndex.value]; const currentText = props.texts[currentTextIndex.value];
if (props.splitBy === 'characters') { switch (props.splitBy) {
const words = currentText.split(' '); case 'characters': {
return words.map((word, i) => ({ const words = currentText.split(' ');
characters: splitIntoCharacters(word), return words.map((word, i) => ({
needsSpace: i !== words.length - 1 characters: splitIntoCharacters(word),
})); needsSpace: i !== words.length - 1
}));
}
case 'words': {
const words = currentText.split(' ');
return words.map((word, i) => ({
characters: [word],
needsSpace: i !== words.length - 1
}));
}
case 'lines': {
const lines = currentText.split('\n');
return lines.map((line, i) => ({
characters: [line],
needsSpace: i !== lines.length - 1
}));
}
default: {
const parts = currentText.split(props.splitBy!);
return parts.map((part, i) => ({
characters: [part],
needsSpace: i !== parts.length - 1
}));
}
} }
if (props.splitBy === 'words') {
return currentText.split(' ').map((word, i, arr) => ({
characters: [word],
needsSpace: i !== arr.length - 1
}));
}
if (props.splitBy === 'lines') {
return currentText.split('\n').map((line, i, arr) => ({
characters: [line],
needsSpace: i !== arr.length - 1
}));
}
return currentText.split(props.splitBy).map((part, i, arr) => ({
characters: [part],
needsSpace: i !== arr.length - 1
}));
}); });
const getStaggerDelay = (index: number, totalChars: number): number => { const getStaggerDelay = (index: number, totalChars: number): number => {
const total = totalChars; const { staggerDuration, staggerFrom } = props;
if (props.staggerFrom === 'first') return index * props.staggerDuration; switch (staggerFrom) {
if (props.staggerFrom === 'last') return (total - 1 - index) * props.staggerDuration; case 'first':
if (props.staggerFrom === 'center') { return index * staggerDuration;
const center = Math.floor(total / 2); case 'last':
return Math.abs(center - index) * props.staggerDuration; return (totalChars - 1 - index) * staggerDuration;
case 'center': {
const center = Math.floor(totalChars / 2);
return Math.abs(center - index) * staggerDuration;
}
case 'random': {
const randomIndex = Math.floor(Math.random() * totalChars);
return Math.abs(randomIndex - index) * staggerDuration;
}
default:
return Math.abs((staggerFrom as number) - index) * staggerDuration;
} }
if (props.staggerFrom === 'random') {
const randomIndex = Math.floor(Math.random() * total);
return Math.abs(randomIndex - index) * props.staggerDuration;
}
return Math.abs((props.staggerFrom as number) - index) * props.staggerDuration;
}; };
const handleIndexChange = (newIndex: number) => { const handleIndexChange = (newIndex: number): void => {
currentTextIndex.value = newIndex; currentTextIndex.value = newIndex;
if (props.onNext) props.onNext(newIndex); props.onNext?.(newIndex);
}; };
const next = () => { const next = (): void => {
const nextIndex = const isAtEnd = currentTextIndex.value === props.texts.length - 1;
currentTextIndex.value === props.texts.length - 1 const nextIndex = isAtEnd ? (props.loop ? 0 : currentTextIndex.value) : currentTextIndex.value + 1;
? props.loop
? 0
: currentTextIndex.value
: currentTextIndex.value + 1;
if (nextIndex !== currentTextIndex.value) { if (nextIndex !== currentTextIndex.value) {
handleIndexChange(nextIndex); handleIndexChange(nextIndex);
} }
}; };
const previous = () => { const previous = (): void => {
const prevIndex = const isAtStart = currentTextIndex.value === 0;
currentTextIndex.value === 0 const prevIndex = isAtStart
? props.loop ? props.loop
? props.texts.length - 1 ? props.texts.length - 1
: currentTextIndex.value : currentTextIndex.value
: currentTextIndex.value - 1; : currentTextIndex.value - 1;
if (prevIndex !== currentTextIndex.value) { if (prevIndex !== currentTextIndex.value) {
handleIndexChange(prevIndex); handleIndexChange(prevIndex);
} }
}; };
const jumpTo = (index: number) => { const jumpTo = (index: number): void => {
const validIndex = Math.max(0, Math.min(index, props.texts.length - 1)); const validIndex = Math.max(0, Math.min(index, props.texts.length - 1));
if (validIndex !== currentTextIndex.value) { if (validIndex !== currentTextIndex.value) {
handleIndexChange(validIndex); handleIndexChange(validIndex);
} }
}; };
const reset = () => { const reset = (): void => {
if (currentTextIndex.value !== 0) { if (currentTextIndex.value !== 0) {
handleIndexChange(0); handleIndexChange(0);
} }
}; };
const cleanupInterval = (): void => {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
};
const startInterval = (): void => {
if (props.auto) {
intervalId = setInterval(next, props.rotationInterval);
}
};
defineExpose({ defineExpose({
next, next,
previous, previous,
@@ -152,30 +184,20 @@ defineExpose({
}); });
watch( watch(
() => [props.auto, props.rotationInterval], () => [props.auto, props.rotationInterval] as const,
() => { () => {
if (intervalId) { cleanupInterval();
clearInterval(intervalId); startInterval();
intervalId = null;
}
if (props.auto) {
intervalId = setInterval(next, props.rotationInterval);
}
}, },
{ immediate: true } { immediate: true }
); );
onMounted(() => { onMounted(() => {
if (props.auto) { startInterval();
intervalId = setInterval(next, props.rotationInterval);
}
}); });
onUnmounted(() => { onUnmounted(() => {
if (intervalId) { cleanupInterval();
clearInterval(intervalId);
}
}); });
</script> </script>
@@ -201,9 +223,9 @@ onUnmounted(() => {
> >
<span v-for="(wordObj, wordIndex) in elements" :key="wordIndex" :class="cn('inline-flex', splitLevelClassName)"> <span v-for="(wordObj, wordIndex) in elements" :key="wordIndex" :class="cn('inline-flex', splitLevelClassName)">
<Motion <Motion
tag="span"
v-for="(char, charIndex) in wordObj.characters" v-for="(char, charIndex) in wordObj.characters"
:key="charIndex" :key="charIndex"
tag="span"
:initial="initial" :initial="initial"
:animate="animate" :animate="animate"
:exit="exit" :exit="exit"
@@ -218,7 +240,7 @@ onUnmounted(() => {
> >
{{ char }} {{ char }}
</Motion> </Motion>
<span v-if="wordObj.needsSpace" class="whitespace-pre"> </span> <span v-if="wordObj.needsSpace" class="whitespace-pre"></span>
</span> </span>
</Motion> </Motion>
</AnimatePresence> </AnimatePresence>

View File

@@ -17,7 +17,7 @@
</motion.span> </motion.span>
<RotatingText <RotatingText
:texts="words" :texts="words"
mainClassName="px-2 py-0.5 bg-[#27FF64] text-white overflow-hidden flex justify-center rounded-lg sm:py-1 md:py-2 md:px-3" mainClassName="px-2 py-0.5 bg-[#27FF64] text-[#222] overflow-hidden flex justify-center rounded-lg sm:py-1 md:py-2 md:px-3"
staggerFrom="last" staggerFrom="last"
:initial="{ y: '100%' }" :initial="{ y: '100%' }"
:animate="{ y: 0 }" :animate="{ y: 0 }"