mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-09 08:29:30 -06:00
Refactor RotatingText component props and improve type definitions
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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 }"
|
||||||
|
|||||||
Reference in New Issue
Block a user