写在开头,如果对组件有什么优化想法或者有新组件想法欢迎留言讨论,一起学习!本项目地址请点击这里

一直在想要做一个什么样的组件库,平时一直在开发后台的项目,用来用去都是那几个出名的组件库,这次自己做就做点不一样的,可能适用场景比较少,可是万一呢?

于是删除了一开始创建的input,select,form组件,开始了本篇的主角:卡片切换组件

因为是使用的tailwindcss来控制样式,防止后面使用的时候传入的类名如果与默认类名冲突可能会展示不出来的问题,于是引入了tailwin-merge来对默认样式和自定义样式进行合并。

📁 src目录结构

src/
├── 📄 index.ts                 # 组件统一出口
└── 📁 components/              # 组件管理文件夹
    └── 📁 CardCarousel/        # 卡片切换组件
        ├── 📄 CardCarousel.tsx # 组件主内容
        └── 📄 index.ts         # 组件导出

功能介绍

该组件仅完成卡片切换与展示功能,卡片内容需要用户自定义,目前仅支持统一尺寸的卡片,并且需要从外部传入卡片的宽度,支持配置展示的卡片数量,点击卡片后,居中点击的卡片,有过渡效果。

这个组件目前就实现了最基础的功能,还有很多可以优化的地方,之后再优化,代码贴在文章最后面,需要的可以直接复制走,或者下载这个项目的npm包: npm i cxl-react-components,之后的组件也都会更新到这个包里。

本次更新顺便把这个项目发到npm上了,第一次发以为会很复杂,但其实很简单,直接让ai帮忙发布就好了哈哈哈哈~

其实也就几个步骤,现在本地用npm login登录,然后直接npm publish就可以了,也有可能会跟我一样遇到名字已经被占用或者不太合适的,改个名就好啦!

组件使用

<CardCarousel 
  data={data} 
  visibleCount={3} 
  defaultIndex={2}
  cardWidth={120}
>
  {data.map((item, index) => (
    <Card key={item.id} index={index}>
      <div className="w-64 h-80 bg-white rounded-xl shadow-lg">
        {/* 卡片内容 */}
      </div>
    </Card>
  ))}
</CardCarousel>

组件展示

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

📄 CardCarousel.tsx

import React, { createContext, useContext, useState, useCallback, ReactNode, useRef, useEffect } from 'react';
import { cn } from '../../utils/cn';

// 定义卡片切换的上下文类型
interface CardCarouselContextType {
  currentIndex: number;
  setCurrentIndex: (index: number) => void;
  data: any[];
  visibleCount: number;
  cardSpacing: number;
  cardWidth: number;
  containerHeight: number;
  setContainerHeight: (height: number) => void;
}

// 创建上下文
const CardCarouselContext = createContext<CardCarouselContextType | null>(null);

// 自定义Hook用于使用上下文
export const useCardCarousel = () => {
  const context = useContext(CardCarouselContext);
  if (!context) {
    throw new Error('useCardCarousel must be used within a CardCarousel');
  }
  return context;
};

// 卡片切换组件Props类型
export interface CardCarouselProps {
  /**
   * 卡片数据
   */
  data: any[];
  /**
   * 可见卡片数量
   * 仅支持奇数,若传入偶数,则自动向上取整
   * @default 3
   */
  visibleCount?: number;
  /**
   * 默认选中卡片索引
   * @default 0
   */
  defaultIndex?: number;
  /**
   * 子组件
   */
  children: ReactNode;
  /**
   * 类名
   */
  className?: string;
  /**
   * 卡片间距(像素)
   * 基于卡片宽度的偏移量:
   * - 0: 卡片刚好并排展示(间距等于卡片宽度)
   * - 负数: 卡片重叠展示(间距小于卡片宽度)
   * - 正数: 卡片间有更大间距(间距大于卡片宽度)
   * @default 0
   */
  cardSpacing?: number;
  /**
   * 卡片宽度(像素)
   * 用于计算卡片间距和位置的基础宽度
   * @default 120
   */
  cardWidth?: number;
}

// 卡片组件Props类型
export interface CardProps {
  /**
   * 卡片索引
   */
  index: number;
  /**
   * 子组件
   */
  children: ReactNode;
  /**
   * 类名
   */
  className?: string;
}

// 主容器组件
export const CardCarousel: React.FC<CardCarouselProps> = ({
  data,
  visibleCount = 3,
  defaultIndex = 0,
  children,
  className = '',
  cardSpacing = 0,
  cardWidth = 120,
}) => {
  // 确保visibleCount至少为1
  const actualVisibleCount = isNaN(visibleCount) ? 1 : Math.max(1, visibleCount);
  
  // 确保defaultIndex在有效范围内
  const initialIndex = isNaN(defaultIndex) ? 0 : Math.max(0, Math.min(defaultIndex, data.length - 1));
  
  const [currentIndex, setCurrentIndex] = useState(initialIndex);
  const [containerHeight, setContainerHeight] = useState(0);

  const handleSetCurrentIndex = useCallback((index: number) => {
    if (index >= 0 && index < data.length) {
      setCurrentIndex(index);
    }
  }, [data.length]);

  const contextValue: CardCarouselContextType = {
    currentIndex,
    setCurrentIndex: handleSetCurrentIndex,
    data,
    visibleCount: actualVisibleCount,
    cardSpacing,
    cardWidth,
    containerHeight,
    setContainerHeight,
  };

  return (
    <CardCarouselContext.Provider value={contextValue}>
      <div 
        className={cn('relative flex items-center justify-center w-full overflow-hidden', className)}
        style={{ height: containerHeight > 0 ? `${containerHeight}px` : 'auto' }}
      >
        {children}
      </div>
    </CardCarouselContext.Provider>
  );
};

// 卡片组件
export const Card: React.FC<CardProps> = ({ index, children, className = '' }) => {
  const { currentIndex, setCurrentIndex, data, visibleCount, cardSpacing, cardWidth, setContainerHeight } = useCardCarousel();
  const cardRef = useRef<HTMLDivElement>(null);
  
  if (index < 0 || index >= data.length) {
    return null;
  }

  // 计算卡片在可见区域中的位置
  const getCardPosition = () => {
    const centerIndex = currentIndex;
    const halfVisible = Math.floor(visibleCount / 2);
    
    // 计算相对于中心的位置
    let relativePosition = index - centerIndex;
    
    // 处理循环逻辑
    if (relativePosition > halfVisible) {
      relativePosition -= data.length;
    } else if (relativePosition < -halfVisible) {
      relativePosition += data.length;
    }
    
    return relativePosition;
  };

  const position = getCardPosition();
  const isVisible = Math.abs(position) <= Math.floor(visibleCount / 2);
  const isActive = position === 0;

  // 检测卡片高度并更新容器高度
  useEffect(() => {
    if (cardRef.current && isVisible) {
      const cardHeight = cardRef.current.offsetHeight;
      // 添加一些内边距以确保卡片不会被裁剪
      setContainerHeight(cardHeight + 20);
    }
  }, [isVisible, setContainerHeight]);
  
  // 计算样式
  const getCardStyle = () => {
    if (!isVisible) {
      return {
        opacity: 0,
        transform: 'scale(0.8) translateX(0)',
        zIndex: 0,
      };
    }

    const scale = isActive ? 1 : 0.85;
    const translateX = position * (cardWidth + cardSpacing);
    const zIndex = isActive ? 10 : 5 - Math.abs(position);

    // 根据位置设置不同的缩放源点
    let transformOrigin = 'center center';
    if (!isActive) {
      if (position > 0) {
        // 右侧卡片从左侧中间缩放
        transformOrigin = 'left center';
      } else if (position < 0) {
        // 左侧卡片从右侧中间缩放
        transformOrigin = 'right center';
      }
    }

    return {
      transform: `translateX(${translateX}px) scale(${scale})`,
      transformOrigin,
      zIndex,
      transition: 'all 0.3s ease-in-out',
    };
  };

  const handleClick = () => {
    if (isVisible && !isActive) {
      setCurrentIndex(index);
    }
  };

  return (
    <div
      ref={cardRef}
      className={cn(
        'absolute cursor-pointer select-none transform-gpu will-change-transform',
        isActive ? 'cursor-default card-carousel-active' : 'card-carousel-inactive hover:cursor-pointer',
        className
      )}
      style={getCardStyle()}
      onClick={handleClick}
    >
      {children}
    </div>
  );
};

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐