RadixUI + PandaCSS + React Date Picker 实现一个小而美的日期选择组件

前言

新年好啊各位(迟到祝福) ,本打算2024年开始一个月更一篇文章,结果上个月没有更新😭,万万不可以放弃,这个月开始更起来,先补一下上个月的文章😫

需求来源

仿Pizza-Express首页日历组件

技术栈

NextJs框架 App Router版
UI库:RadixUI无样式版
CSS-IN-JS方案:PandaCSS
React日期组件库:react-datepicker

项目搭建

具体项目搭建,可以查看此链接👀

引入React Date Picker库

pnpm install react-datepicker

pnpm install @types/react-datepicker -D

引入Radix Dialog组件

pnpm install @radix-ui/react-dialog

日期组件成果展示

使用defineKeyframes(PandaCss) 定义全局动画

ts 复制代码
// theme/keyframe/index.ts
import { defineKeyframes } from "@pandacss/dev";
​
export const keyframes = defineKeyframes({
  overlayShow: {
    "0%": { opacity: "0" },
    "100%": { opacity: "0.5" },
  },
  contentShow: {
    from: {
      opacity: "0",
      transform: "translate(-50%, 0%) scale(0.96)",
    },
    to: {
      opacity: "1",
      transform: "translate(-50%, 0%) scale(1)",
    },
  },
  overlayHide: {
    "0%": { opacity: "0.5" },
    "100%": { opacity: "0" },
  },
  contentHide: {
    from: {
      opacity: "1",
      transform: "translate(-50%, 0%) scale(1)",
    },
    to: {
      opacity: "0",
      transform: "translate(-50%, 0%) scale(0.96)",
    },
  },
});
```css

### 如何实用全局定义的动画

    modalOverlay: css({
        pos: "fixed",
        bgColor: "#000",
        inset: 0,
        opacity: "0.5",
        "&[data-state='open']": {
        animation: "overlayShow 250ms ease-in",
        },
        "&[data-state='closed']": {
        animation: "overlayHide 250ms ease-out",
        },
    }),

直接用animation,指定定义在defineKeyframes中的属性名即可

Recipe配方创建可供多种选择的样式按钮(PandaCss)

两种方式,一种通过PandaCss提供的cva函数创建Recipe配方,另一种则是通过defineRecipe定义Recipe配方并注入到全局Recipe配方

本文中采用全局Recipe配方配置,cva函数实现起来类似

ts 复制代码
// theme/recipes/button.ts
import { defineRecipe } from "@pandacss/dev";
​
export default defineRecipe({
  className: "customer_link_btn",
  base: {
    display: "inline-flex",
    flexDir: "row",
    alignItems: "center",
    cursor: "pointer",
    w: "full",
    borderRadius: "4rem",
    minH: "3.625rem",
    textDecoration: "none",
    fontWeight: "400",
    transition: "all .15s ease-in",
  },
  variants: {
    size: {
      sm: {
        p: "0.625rem 1.25rem",
      },
      md: {
        p: "0.75rem 1.5rem",
      },
    },
    pos: {
      begin: {
        justifyContent: "flex-start",
      },
      center: {
        justifyContent: "center",
      },
    },
    variant: {
      grey: {
        bgColor: "#f7f5f4",
      },
      light: {
        boxShadow: "inset 0 0 0 2px #fff",
        color: "#fff",
      },
      dark: {
        backgroundColor: "#1c1a1a",
        color: "#fff",
      },
    },
  },
  defaultVariants: {
    variant: "grey",
    size: "sm",
    pos: "begin",
  },
});
​
// theme/recipes/index.ts
import buttonRecipe from "./button";
export const recipes = {
  button: buttonRecipe,
};

Recipe配方重要的有以下四个属性

  • base: 组件的基础样式
  • variants: 组件的不同视觉样式,一些通过视觉上看到的css样式,例如背景颜色,大小等可以在此定义
  • compoundVariants: 组件的不同变体组合,这个属性本文中没有用到,想要了解的可以参考以下链接
  • defaultVariants: 组件的默认变量值,在未指定variants中属性时,赋予其默认值

还有一个className属性,该组件在浏览器中对应dom元素的类名

关于PandaCSS Recipe配方使用,可以参考该链接

添加至panda.config.ts配置文件

ts 复制代码
    // panda.config.ts
    import { defineConfig } from "@pandacss/dev";
    import { recipes } from "./theme/recipes";
    ​
    export default defineConfig({
      preflight: true,
      include: ["./**/*.{js,jsx,ts,tsx}", "./app/**/*.{ts,tsx,js,jsx}"],
      exclude: ["node_modules"],
      theme: {
        recipes,
      },
      jsxFramework: "react",
      jsxFactory: "panda",
      outdir: "styled-system",
    });

配置完配方后,最好重新生成一下styled-system文件(执行panda codegen)

开始封装IconButton组件

tsx 复制代码
    import { RecipeVariantProps, cx } from "@/styled-system/css";
    import { button } from "@/styled-system/recipes";
    import { forwardRef } from "react";
    import Image from "next/image";
    import type { ButtonHTMLAttributes, ReactNode } from "react";
    import { IconButtonClasses } from "./IconButton.style";
    type ButtonElementProps = ButtonHTMLAttributes<HTMLButtonElement>;
    type RecipeProps = RecipeVariantProps<typeof button>;
    type ButtonRecipeProps = RecipeProps &
      Omit<ButtonElementProps, keyof RecipeProps>;
    ​
    interface IProps {
      icon?: string;
      className?: string;
      onClick?: () => void;
      children?: ReactNode;
    }
    ​
    export const IconButton = forwardRef<HTMLButtonElement, ButtonRecipeProps & IProps>(
      ({ children, className, size, pos, variant, icon, ...props }, ref) => {
        const classesBtn = button({ variant, size, pos });
        return (
          <div className={icon ? cx(IconButtonClasses.iconCalendar, className) : className}>
            <button
              className={`${classesBtn} ${className ?? ""}`}
              ref={ref}
              {...props}
            >
              {children}
            </button>
            {icon && (
              <Image className={IconButtonClasses.Icon} src={icon} alt="iconButton"></Image>
            )}
          </div>
        );
      }
    );
    ​
    IconButton.displayName = "IconButton";

Panda会根据全局配置的Recipe配方,将对应Recipe配方中所需要的组件类型以及方法自动的生成出来,例如定义配方中variants的类型

感兴趣的可以查看styled-system/recipes文件夹下的生成类型文件进行了解

这里的TS类型声明主要是去除ButtonElementProps类型下与ButtonRecipeProps类型下重名的属性,再拼接ButtonRecipeProps与IProps

封装这个IconButton组件用了React Hook forwardRef ,这点非常重要,但你会发现如果去除掉forwardRef,有时也可以达到一样的效果,不会影响此次实现功能(测试了一下没啥毛病)。但仔细阅读RadixUI官网,这个写法是非常有必要的,具体原因接下来再讲

React Hook forwardRef相关知识

有了"基础"的IconButton组件,具体的实现效果如下

tsx 复制代码
<IconButton variant="grey" size="sm" pos="begin" icon={calendarIcon}>
  Grey Button With Icon
</IconButton>
<IconButton variant="dark" size="sm" pos="begin" icon={calendarIconLight}>
  Dark Button With Icon
</IconButton>
<IconButton variant="grey" size="md" pos="center">
  Grey Button Center
</IconButton>
<IconButton variant="dark" size="md" pos="begin">
  Grey Button Begin
</IconButton>

Radix Primitives组件实现Dialog弹出层封装

tsx 复制代码
import { Dispatch, ReactNode, SetStateAction } from "react";
import Image from "next/image";
import closeBtn from "@/svgs/closeIcon.svg";
import * as Dialog from "@radix-ui/react-dialog";
import { ModalClasses } from "./Modal.style";
interface IProps {
  openDatePicker: boolean;
  setIsOpenDatePicker: Dispatch<SetStateAction<boolean>>;
  top?: number;
  TriggerElement: ReactNode;
  ContentElement: ReactNode;
}
export default function Modal({
  TriggerElement,
  ContentElement,
  top = 10,
  openDatePicker,
  setIsOpenDatePicker,
}: IProps) {
  return (
    <Dialog.Root
      open={openDatePicker}
      onOpenChange={() => {
        setIsOpenDatePicker(!openDatePicker);
      }}
    >
      <Dialog.Trigger asChild>{TriggerElement}</Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay className={ModalClasses.modalOverlay}></Dialog.Overlay>
        <Dialog.Content
          style={{ top: `${top}%` }}
          className={ModalClasses.modalContent}
          asChild
        >
          <div>
            {ContentElement}
            <Dialog.Close asChild>
              <button className={ModalClasses.closeModalBtn}>
                <Image src={closeBtn} alt="close"></Image>
              </button>
            </Dialog.Close>
          </div>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

先放出官网封装的建议

Radix Primitives 组件中的asChild属性,当设置asChild属性时(设置为true),其下组件将不会呈现默认的 DOM 元素,而是向其子组件(也可以是你自定义的组件)传递功能所需的函数和参数 ,因此以上封装的IconButton组件中,RadixUI存在某些提供的组件会向它的子组件传递Ref,以及一些其它的参数,所以在封装IconButton组件时,万金油的...props以及forwardRef必不可少

完成了Dialog弹出层封装,效果如下

接下来就是ReactDatePicker组件的封装

tsx 复制代码
    import { Dispatch, ReactNode, SetStateAction } from "react";
    import ReactDatePicker, {
      ReactDatePickerCustomHeaderProps,
    } from "react-datepicker";
    import "react-datepicker/dist/react-datepicker.css";
    import "./index.css";
    import { getFormatDate, getMonth, getYears } from "@/utils/Date";
    import mouthprev from "@/svgs/mouth-prev.svg";
    import mouthnext from "@/svgs/mouth-next.svg";
    import Image from "next/image";
    import { IconButton } from "../IconButton";
    import { SelectDateClasses } from "./SelectDate.style";
    const FUTUREDATE_LIMIT = 6;
    const CHANGE_MONTH = 1;
    ​
    interface IProps {
      startDate: Date;
      setStartDate: Dispatch<SetStateAction<Date>>;
      openDatePicker: boolean;
      setIsOpenDatePicker: Dispatch<SetStateAction<boolean>>;
    }
    ​
    export default function SelectDate({
      startDate,
      setStartDate,
      setIsOpenDatePicker,
      openDatePicker,
    }: IProps) {
      const maxDate = new Date();
      maxDate.setMonth(maxDate.getMonth() + FUTUREDATE_LIMIT);
    ​
      const filterDate = (date: Date) => {
        const now = new Date();
        now.setHours(0, 0, 0, 0);
        return date >= now && date <= maxDate;
      };
    ​
      const CalendarContainer = ({ children }: { children: ReactNode }) => {
        return <>{children}</>;
      };
    ​
      const CustomHeader = ({
        decreaseMonth,
        increaseMonth,
        prevMonthButtonDisabled,
        nextMonthButtonDisabled,
      }: ReactDatePickerCustomHeaderProps) => {
        return (
          <div className={SelectDateClasses.customerHeader}>
            <button
              onClick={() => {
                decreaseMonth();
                setStartDate((prevDate) => {
                  const newDate = new Date(prevDate);
                  newDate.setMonth(prevDate.getMonth() - CHANGE_MONTH);
                  return newDate;
                });
              }}
              disabled={prevMonthButtonDisabled}
            >
              <Image src={mouthprev} alt="mouthprew"></Image>
            </button>
            <span>{getMonth(startDate)}</span>
            <span>{getYears(startDate)}</span>
            <button
              onClick={() => {
                increaseMonth();
                setStartDate((prevDate) => {
                  const newDate = new Date(prevDate);
                  newDate.setMonth(prevDate.getMonth() + CHANGE_MONTH);
                  return newDate;
                });
              }}
              disabled={nextMonthButtonDisabled}
            >
              <Image src={mouthnext} alt="mouthnext"></Image>
            </button>
          </div>
        );
      };
    ​
      return (
        <div className={SelectDateClasses.modalContent}>
          <div className={SelectDateClasses.dateDisplay}>
            <span>{startDate.getFullYear()}</span>
            <span style={{ paddingTop: "0.625rem" }}>
              {getFormatDate(startDate)}
            </span>
          </div>
          <div className={SelectDateClasses.datePicker}>
            <ReactDatePicker
              inline
              selected={startDate}
              calendarContainer={CalendarContainer}
              renderCustomHeader={CustomHeader}
              onChange={(date) => setStartDate(date!)}
              filterDate={filterDate}
            ></ReactDatePicker>
            <IconButton
              variant="dark"
              pos="center"
              className={SelectDateClasses.linkButton}
              onClick={() => {
                setIsOpenDatePicker(!openDatePicker);
                setStartDate(startDate);
              }}
            >
              OK
            </IconButton>
          </div>
        </div>
      );
    }

修改默认样式,肝CSS样式就完了😭,以及查阅React Datepicker的文档,查看ReactDatePicker的基本使用

完整项目地址

最后

层层封装,一个日期选择组件封装完成,体验下来RadixUI很适合来写UI组件库,配合PandaCss的CSS-in-JS方案,可以定制化主题,定制多种样式规则,本文源代码已经上传至Github,有兴趣的小伙伴可以下载一起学习,后续可以继续封装一些更加实用的组件

相关推荐
Easonmax14 分钟前
【CSS3】css开篇基础(1)
前端·css
大鱼前端33 分钟前
未来前端发展方向:深度探索与技术前瞻
前端
昨天;明天。今天。38 分钟前
案例-博客页面简单实现
前端·javascript·css
天上掉下来个程小白39 分钟前
请求响应-08.响应-案例
java·服务器·前端·springboot
周太密1 小时前
使用 Vue 3 和 Element Plus 构建动态酒店日历组件
前端
时清云2 小时前
【算法】合并两个有序链表
前端·算法·面试
小爱丨同学2 小时前
宏队列和微队列
前端·javascript
持久的棒棒君2 小时前
ElementUI 2.x 输入框回车后在调用接口进行远程搜索功能
前端·javascript·elementui
2401_857297912 小时前
秋招内推2025-招联金融
java·前端·算法·金融·求职招聘
undefined&&懒洋洋3 小时前
Web和UE5像素流送、通信教程
前端·ue5