前端 2024年 前端 React RadixUI + PandaCSS + React Date Picker 实现一个小而美的日期选择组件 Chen BY 2024-03-07 2024-03-07 前言 新年好啊各位(迟到祝福) ,本打算2024年开始一个月更一篇文章,结果上个月没有更新😭,万万不可以放弃,这个月开始更起来,先补一下上个月的文章😫
需求来源 仿Pizza-Express 首页日历组件
技术栈 项目搭建 具体项目搭建,可以查看此链接 👀
引入React Date Picker库
pnpm install react-datepicker
pnpm install @types/react-datepicker -D
引入Radix Dialog组件
pnpm install @radix-ui/react-dialog
日期组件成果展示
使用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 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 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" , }, }); 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 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,有兴趣的小伙伴可以下载一起学习,后续可以继续封装一些更加实用的组件