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

日期组件成果展示

tutieshi_640x442_17s

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 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函数实现起来类似

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// 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配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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组件,具体的实现效果如下

1
2
3
4
5
6
7
8
9
10
11
12
<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弹出层封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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组件的封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
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,有兴趣的小伙伴可以下载一起学习,后续可以继续封装一些更加实用的组件