前言
新年好啊各位(迟到祝福) ,本打算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官网,这个写法是非常有必要的,具体原因接下来再讲
有了"基础"的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,有兴趣的小伙伴可以下载一起学习,后续可以继续封装一些更加实用的组件