feat: 更新营销页面组件与交互优化
- 删除 .impeccable.md - 新增 detail-swipe-nav, loading-state, skeleton, tooltip 组件 - 新增 use-keyboard-shortcuts, use-swipe-gesture hooks - 更新 contact, news, products, services, solutions 等页面 - 优化 header, mobile-menu, input, page-transition 组件 - 添加 terms 与错误追踪测试页面
This commit is contained in:
@@ -196,7 +196,7 @@ function HeaderContent() {
|
||||
className="fixed inset-0 z-40 md:hidden"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/30 backdrop-blur-sm"
|
||||
className="absolute inset-0 bg-[var(--color-primary)]/30 backdrop-blur-sm"
|
||||
onClick={() => setIsOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -226,7 +226,7 @@ function HeaderContent() {
|
||||
block px-4 py-4 text-base font-medium rounded-lg
|
||||
transition-all duration-200
|
||||
${isActive(item)
|
||||
? 'text-[var(--color-text-primary)] bg-[var(--color-primary-lighter)] border-l-4 border-[var(--color-brand-primary)]'
|
||||
? 'text-[var(--color-text-primary)] bg-[var(--color-brand-primary-bg)]'
|
||||
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-primary-lighter)]'
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -61,7 +61,7 @@ export function MobileMenu({ className }: MobileMenuProps) {
|
||||
{isOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40"
|
||||
className="fixed inset-0 bg-[var(--color-primary)]/20 backdrop-blur-sm z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { PRODUCTS, SERVICES } from '@/lib/constants';
|
||||
import { SwipeNavigation } from '@/hooks/use-swipe-gesture';
|
||||
|
||||
interface DetailSwipeNavProps {
|
||||
type: 'product' | 'service' | 'solution';
|
||||
currentId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DetailSwipeNav({ type, currentId, className }: DetailSwipeNavProps) {
|
||||
let items: { id: string; title: string }[] = [];
|
||||
|
||||
if (type === 'product') {
|
||||
items = PRODUCTS.map(p => ({ id: p.id, title: p.title }));
|
||||
} else if (type === 'service') {
|
||||
items = SERVICES.map(s => ({ id: s.id, title: s.title }));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentIndex = items.findIndex(item => item.id === currentId);
|
||||
|
||||
if (currentIndex === -1) return null;
|
||||
|
||||
const prevItem = currentIndex > 0 ? items[currentIndex - 1] : null;
|
||||
const nextItem = currentIndex < items.length - 1 ? items[currentIndex + 1] : null;
|
||||
|
||||
const basePath = type === 'product' ? '/products' : type === 'service' ? '/services' : `/solutions`;
|
||||
|
||||
return (
|
||||
<SwipeNavigation
|
||||
prevRoute={prevItem ? `${basePath}/${prevItem.id}` : undefined}
|
||||
nextRoute={nextItem ? `${basePath}/${nextItem.id}` : undefined}
|
||||
prevLabel={prevItem?.title || ''}
|
||||
nextLabel={nextItem?.title || ''}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
label?: string | React.ReactNode
|
||||
error?: string
|
||||
'data-testid'?: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Skeleton,
|
||||
SkeletonHero,
|
||||
SkeletonList,
|
||||
SkeletonCard,
|
||||
SkeletonForm,
|
||||
} from './skeleton';
|
||||
|
||||
type LoadingVariant = 'hero' | 'list' | 'card' | 'form' | 'custom' | 'spinner';
|
||||
|
||||
interface LoadingStateProps {
|
||||
isLoading: boolean;
|
||||
children: React.ReactNode;
|
||||
variant?: LoadingVariant;
|
||||
listItems?: number;
|
||||
formFields?: number;
|
||||
className?: string;
|
||||
delayMs?: number;
|
||||
customSkeleton?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LoadingState({
|
||||
isLoading,
|
||||
children,
|
||||
variant = 'custom',
|
||||
listItems = 3,
|
||||
formFields = 4,
|
||||
className,
|
||||
delayMs = 150,
|
||||
customSkeleton,
|
||||
}: LoadingStateProps) {
|
||||
const [showSkeleton, setShowSkeleton] = useState(false);
|
||||
const prevIsLoading = useRef(isLoading);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading !== prevIsLoading.current) {
|
||||
prevIsLoading.current = isLoading;
|
||||
|
||||
if (!isLoading) {
|
||||
setShowSkeleton(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => setShowSkeleton(true), delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [isLoading, delayMs]);
|
||||
|
||||
if (isLoading && showSkeleton) {
|
||||
return (
|
||||
<div
|
||||
className={cn('animate-in fade-in duration-300', className)}
|
||||
role="status"
|
||||
aria-label="内容加载中"
|
||||
aria-busy="true"
|
||||
>
|
||||
{variant === 'hero' && <SkeletonHero />}
|
||||
{variant === 'list' && <SkeletonList items={listItems} />}
|
||||
{variant === 'card' && (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{variant === 'form' && <SkeletonForm fields={formFields} />}
|
||||
{variant === 'custom' && (customSkeleton || <Skeleton className="h-40 w-full rounded-lg" />)}
|
||||
{variant === 'spinner' && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
interface SpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Spinner({ size = 'md', className }: SpinnerProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-8 h-8',
|
||||
lg: 'w-12 h-12',
|
||||
};
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={cn(
|
||||
'animate-spin text-[var(--color-brand-primary)]',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
role="status"
|
||||
aria-label="加载中"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageLoaderProps {
|
||||
isLoading: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function PageLoader({ isLoading, message = '正在加载...' }: PageLoaderProps) {
|
||||
if (!isLoading) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--color-bg-primary)]/80 backdrop-blur-sm"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-label={message}
|
||||
>
|
||||
<div className="text-center space-y-4">
|
||||
<Spinner size="lg" className="mx-auto" />
|
||||
<p className="text-sm text-[var(--color-text-muted)] animate-pulse">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLoadingState(initialState = false, delayMs = 150) {
|
||||
const [isLoading, setIsLoading] = useState(initialState);
|
||||
const [shouldShow, setShouldShow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setShouldShow(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => setShouldShow(true), delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isLoading, delayMs]);
|
||||
|
||||
const startLoading = useCallback(() => setIsLoading(true), []);
|
||||
const stopLoading = useCallback(() => setIsLoading(false), []);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
shouldShow,
|
||||
startLoading,
|
||||
stopLoading,
|
||||
setLoading: setIsLoading,
|
||||
};
|
||||
}
|
||||
@@ -2,47 +2,112 @@
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ReactNode, useSyncExternalStore } from 'react';
|
||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||
|
||||
interface PageTransitionProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function useIsMounted() {
|
||||
return useSyncExternalStore(
|
||||
(callback) => {
|
||||
window.addEventListener('resize', callback);
|
||||
return () => window.removeEventListener('resize', callback);
|
||||
const pageVariants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: 8,
|
||||
scale: 0.98,
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: [0.25, 1, 0.5, 1] as const,
|
||||
staggerChildren: 0.05,
|
||||
},
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -8,
|
||||
scale: 0.98,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: [0.22, 1, 0.36, 1] as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function PageTransition({ children }: PageTransitionProps) {
|
||||
export function PageTransition({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const mounted = useIsMounted();
|
||||
|
||||
if (shouldReduceMotion || !mounted) {
|
||||
if (shouldReduceMotion) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
variants={pageVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
style={{
|
||||
willChange: 'transform, opacity',
|
||||
backfaceVisibility: 'hidden',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sectionVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: (i: number) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
delay: i * 0.08,
|
||||
duration: 0.5,
|
||||
ease: [0.25, 1, 0.5, 1] as const,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export function StaggerChildren({
|
||||
children,
|
||||
className = '',
|
||||
staggerCount = 4
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
staggerCount?: number;
|
||||
}) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
if (shouldReduceMotion) {
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.06,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{Array.isArray(children)
|
||||
? children.map((child, i) => (
|
||||
<motion.div key={i} custom={Math.min(i, staggerCount)} variants={sectionVariants}>
|
||||
{child}
|
||||
</motion.div>
|
||||
))
|
||||
: children
|
||||
}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
variant?: 'default' | 'circular' | 'rounded';
|
||||
}
|
||||
|
||||
export function Skeleton({ className, variant = 'default' }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-pulse bg-[var(--color-bg-section)]',
|
||||
variant === 'circular' && 'rounded-full',
|
||||
variant === 'rounded' && 'rounded-lg',
|
||||
variant === 'default' && 'rounded',
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
role="presentation"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonTextProps {
|
||||
lines?: number;
|
||||
className?: string;
|
||||
lastLineWidth?: string;
|
||||
}
|
||||
|
||||
export function SkeletonText({
|
||||
lines = 3,
|
||||
className,
|
||||
lastLineWidth = '75%'
|
||||
}: SkeletonTextProps) {
|
||||
return (
|
||||
<div className={cn('space-y-3', className)} aria-hidden="true" role="presentation">
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className={cn(
|
||||
'h-4',
|
||||
i === lines - 1 ? `w-[${lastLineWidth}]` : 'w-full'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonCardProps {
|
||||
className?: string;
|
||||
showImage?: boolean;
|
||||
showTitle?: boolean;
|
||||
showDescription?: boolean;
|
||||
showFooter?: boolean;
|
||||
}
|
||||
|
||||
export function SkeletonCard({
|
||||
className,
|
||||
showImage = true,
|
||||
showTitle = true,
|
||||
showDescription = true,
|
||||
showFooter = false,
|
||||
}: SkeletonCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'p-6 rounded-xl border border-[var(--color-border-primary)] bg-[var(--color-bg-primary)]',
|
||||
className
|
||||
)}
|
||||
role="status"
|
||||
aria-label="加载中"
|
||||
aria-busy="true"
|
||||
>
|
||||
{showImage && (
|
||||
<Skeleton className="h-40 w-full rounded-lg mb-4" />
|
||||
)}
|
||||
|
||||
{showTitle && (
|
||||
<Skeleton className="h-6 w-3/4 mb-3" />
|
||||
)}
|
||||
|
||||
{showDescription && (
|
||||
<SkeletonText lines={2} lastLineWidth="90%" />
|
||||
)}
|
||||
|
||||
{showFooter && (
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Skeleton className="h-10 w-24 rounded-lg" />
|
||||
<Skeleton className="h-10 w-20 rounded-lg" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonListProps {
|
||||
items?: number;
|
||||
className?: string;
|
||||
variant?: 'card' | 'list' | 'compact';
|
||||
}
|
||||
|
||||
export function SkeletonList({
|
||||
items = 3,
|
||||
className,
|
||||
variant = 'card',
|
||||
}: SkeletonListProps) {
|
||||
return (
|
||||
<div className={cn('space-y-4', className)} role="status" aria-label="内容列表加载中">
|
||||
{Array.from({ length: items }).map((_, i) => (
|
||||
<div key={i}>
|
||||
{variant === 'card' && <SkeletonCard />}
|
||||
{variant === 'list' && (
|
||||
<div className="flex items-center gap-4 p-4">
|
||||
<Skeleton className="w-12 h-12 rounded-lg shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
<Skeleton className="w-16 h-8 rounded-lg shrink-0" />
|
||||
</div>
|
||||
)}
|
||||
{variant === 'compact' && (
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<Skeleton className="w-8 h-8 rounded shrink-0" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-2 w-12 rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonHeroProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SkeletonHero({ className }: SkeletonHeroProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('pt-32 pb-16', className)}
|
||||
role="status"
|
||||
aria-label="页面标题加载中"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div className="container-wide max-w-3xl space-y-6">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-12 md:h-14 w-full max-w-2xl" />
|
||||
<Skeleton className="h-6 w-full max-w-xl" />
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Skeleton className="h-12 w-36 rounded-lg" />
|
||||
<Skeleton className="h-12 w-28 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonFormProps {
|
||||
fields?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SkeletonForm({ fields = 4, className }: SkeletonFormProps) {
|
||||
return (
|
||||
<div className={cn('space-y-6 max-w-2xl', className)} role="status" aria-label="表单加载中">
|
||||
{Array.from({ length: fields }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-14 w-full rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
<Skeleton className="h-14 w-full rounded-lg mt-8" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface TooltipProps {
|
||||
content: string;
|
||||
children: React.ReactNode;
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Tooltip({ content, children, side = 'top', className }: TooltipProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const showTooltip = () => {
|
||||
if (!triggerRef.current || !tooltipRef.current) return;
|
||||
|
||||
const triggerRect = triggerRef.current.getBoundingClientRect();
|
||||
const tooltipRect = tooltipRef.current.getBoundingClientRect();
|
||||
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
|
||||
switch (side) {
|
||||
case 'top':
|
||||
top = triggerRect.top - tooltipRect.height - 8;
|
||||
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
|
||||
break;
|
||||
case 'bottom':
|
||||
top = triggerRect.bottom + 8;
|
||||
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
|
||||
break;
|
||||
case 'left':
|
||||
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
|
||||
left = triggerRect.left - tooltipRect.width - 8;
|
||||
break;
|
||||
case 'right':
|
||||
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
|
||||
left = triggerRect.right + 8;
|
||||
break;
|
||||
}
|
||||
|
||||
setPosition({ top, left });
|
||||
setIsVisible(true);
|
||||
};
|
||||
|
||||
const hideTooltip = () => {
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') hideTooltip();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isVisible]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={triggerRef}
|
||||
className="relative inline-flex"
|
||||
onMouseEnter={showTooltip}
|
||||
onMouseLeave={hideTooltip}
|
||||
onFocus={showTooltip}
|
||||
onBlur={hideTooltip}
|
||||
>
|
||||
{children}
|
||||
{isVisible && (
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
role="tooltip"
|
||||
className={cn(
|
||||
'fixed z-50 px-3 py-2 text-sm rounded-lg bg-[var(--color-primary)] text-white shadow-lg',
|
||||
'pointer-events-none animate-in fade-in-0 zoom-in-95 duration-200',
|
||||
'max-w-xs',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute w-2 h-2 bg-[var(--color-primary)] rotate-45',
|
||||
side === 'top' && 'bottom-[-4px] left-1/2 -translate-x-1/2',
|
||||
side === 'bottom' && 'top-[-4px] left-1/2 -translate-x-1/2',
|
||||
side === 'left' && 'right-[-4px] top-1/2 -translate-y-1/2',
|
||||
side === 'right' && 'left-[-4px] top-1/2 -translate-y-1/2'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user