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,有兴趣的小伙伴可以下载一起学习,后续可以继续封装一些更加实用的组件

相关推荐
gnip1 小时前
链式调用和延迟执行
前端·javascript
SoaringHeart2 小时前
Flutter组件封装:页面点击事件拦截
前端·flutter
杨天天.2 小时前
小程序原生实现音频播放器,下一首上一首切换,拖动进度条等功能
前端·javascript·小程序·音视频
Dragon Wu2 小时前
React state在setInterval里未获取最新值的问题
前端·javascript·react.js·前端框架
Jinuss2 小时前
Vue3源码reactivity响应式篇之watch实现
前端·vue3
YU大宗师2 小时前
React面试题
前端·javascript·react.js
木兮xg2 小时前
react基础篇
前端·react.js·前端框架
ssshooter2 小时前
你知道怎么用 pnpm 临时给某个库打补丁吗?
前端·面试·npm
IT利刃出鞘3 小时前
HTML--最简的二级菜单页面
前端·html
yume_sibai3 小时前
HTML HTML基础(4)
前端·html