目录
一、button
组件来源:https://ui.shadcn.com/docs/components/button
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
disabled:
"disabled-foreground bg-disabled text-disabled-foreground",
ghost: "hover:bg-accent focus-visible:ring-0 focus-visible:ring-offset-0",
link: "hover:text-primary underline-offset-4",
icon: "border border-input",
},
size: {
default: "h-8 px-5 py-1.5",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-6 w-6",
iconSm: "h-8 w-8",
ssm: "h-6",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
},
)
Button.displayName = "Button"
export { Button, buttonVariants }
这段代码定义了一个可变样式的按钮组件Button
,使用了多个工具和库。我们将逐步解释各部分代码的作用。
1. 导入部分
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
React
: 导入React库。Slot
: 从@radix-ui/react-slot
库中导入Slot
组件,用于支持“asChild”属性。cva
和VariantProps
: 从class-variance-authority
库中导入,用于定义可变样式。cn
: 从项目中的utils
工具库导入cn
函数,用于合并CSS类名。
2. 定义按钮的样式变体
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
disabled: "disabled-foreground bg-disabled text-disabled-foreground",
ghost: "hover:bg-accent focus-visible:ring-0 focus-visible:ring-offset-0",
link: "hover:text-primary underline-offset-4",
icon: "border border-input",
},
size: {
default: "h-8 px-5 py-1.5",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-6 w-6",
iconSm: "h-8 w-8",
ssm: "h-6",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
buttonVariants
: 使用cva
函数定义了按钮的样式变体。这个对象包含了两个主要部分:variants
: 定义了不同的变体选项,如variant
和size
,每个选项又包含不同的具体样式。defaultVariants
: 定义了默认的变体值。
当然,让我们逐一解释variants
对象中的每个属性及其对应的CSS属性值。
1. variant
variant
属性定义了按钮的多种视觉风格,每种风格都对应一组CSS类名。
default
bg-primary { background-color: var(--primary-color); } text-primary-foreground { color: var(--primary-foreground-color); } hover:bg-primary/90 { background-color: var(--primary-color); opacity: 0.9; }
destructive
bg-destructive { background-color: var(--destructive-color); } text-destructive-foreground { color: var(--destructive-foreground-color); } hover:bg-destructive/90 { background-color: var(--destructive-color); opacity: 0.9; }
outline
border border-input { border: 1px solid var(--input-border-color); } bg-background { background-color: var(--background-color); } hover:bg-accent { background-color: var(--accent-color); } hover:text-accent-foreground { color: var(--accent-foreground-color); }
secondary
bg-secondary { background-color: var(--secondary-color); } text-secondary-foreground { color: var(--secondary-foreground-color); } hover:bg-secondary/80 { background-color: var(--secondary-color); opacity: 0.8; }
disabled
disabled-foreground { color: var(--disabled-foreground-color); } bg-disabled { background-color: var(--disabled-color); } text-disabled-foreground { color: var(--disabled-foreground-color); }
ghost
hover:bg-accent { background-color: var(--accent-color); } focus-visible:ring-0 { outline: none; box-shadow: none; } focus-visible:ring-offset-0 { box-shadow: none; }
link
hover:text-primary { color: var(--primary-color); } underline-offset-4 { text-underline-offset: 4px; }
icon
border border-input { border: 1px solid var(--input-border-color); }
2. size
size
属性定义了按钮的不同尺寸,每个尺寸都对应一组CSS类名。
default
h-8 { height: 2rem; /* 32px */ } px-5 { padding-left: 1.25rem; /* 20px */ padding-right: 1.25rem; /* 20px */ } py-1.5 { padding-top: 0.375rem; /* 6px */ padding-bottom: 0.375rem; /* 6px */ }
sm
h-9 { height: 2.25rem; /* 36px */ } rounded-md { border-radius: 0.375rem; /* 6px */ } px-3 { padding-left: 0.75rem; /* 12px */ padding-right: 0.75rem; /* 12px */ }
lg
h-11 { height: 2.75rem; /* 44px */ } rounded-md { border-radius: 0.375rem; /* 6px */ } px-8 { padding-left: 2rem; /* 32px */ padding-right: 2rem; /* 32px */ }
icon
h-6 { height: 1.5rem; /* 24px */ } w-6 { width: 1.5rem; /* 24px */ }
iconSm
h-8 { height: 2rem; /* 32px */ } w-8 { width: 2rem; /* 32px */ }
ssm
h-6 { height: 1.5rem; /* 24px */ }
总结
这些variants
属性提供了丰富的样式变体,使得按钮组件可以根据不同的需求应用不同的外观和尺寸,通过简单的属性传递实现了多样化的视觉效果。
3. 定义按钮的属性类型
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
ButtonProps
: 定义了按钮组件的属性接口,扩展了React.ButtonHTMLAttributes
和VariantProps
。另外,还增加了asChild
属性,用于指定是否将按钮作为子组件渲染。
4. 定义按钮组件
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
},
)
Button.displayName = "Button"
Button
: 使用React.forwardRef
定义一个带有转发引用(ref)的按钮组件。Comp
: 根据asChild
属性,动态决定使用Slot
组件还是button
元素。cn(buttonVariants({ variant, size, className }))
: 使用cn
函数合并传入的className
和通过buttonVariants
生成的变体样式。
5. 导出组件和样式变体
export { Button, buttonVariants }
Button
和buttonVariants
均被导出,允许在其他模块中使用。
总结
这个组件使用了class-variance-authority
库来管理按钮的样式变体,通过React的forwardRef
和条件渲染实现了灵活的按钮组件。这样,开发者可以通过简单的属性传递来改变按钮的外观和行为。
二、multi-select
组件来源:https://shadcn-extension.vercel.app/docs/multi-select
如果希望有远程搜索能力,可以参考 https://shadcnui-expansions.typeart.cc/docs/multiple-selector
"use client";
import { Badge } from "@/components/ui/badge";
import {
Command,
CommandItem,
CommandEmpty,
CommandList,
} from "@/components/ui/command";
import { cn } from "@/lib/utils";
import { Command as CommandPrimitive } from "cmdk";
import { X as RemoveIcon, Check } from "lucide-react";
import React, {
KeyboardEvent,
createContext,
forwardRef,
useCallback,
useContext,
useState,
} from "react";
type MultiSelectorProps = {
values: string[];
onValuesChange: (value: string[]) => void;
loop?: boolean;
options: {label: string, value: string, color?: string}[];
} & React.ComponentPropsWithoutRef<typeof CommandPrimitive>;
interface MultiSelectContextProps {
value: string[];
options: {label: string, value: string, color?: string}[];
onValueChange: (value: any) => void;
open: boolean;
setOpen: (value: boolean) => void;
inputValue: string;
setInputValue: React.Dispatch<React.SetStateAction<string>>;
activeIndex: number;
setActiveIndex: React.Dispatch<React.SetStateAction<number>>;
}
const MultiSelectContext = createContext<MultiSelectContextProps | null>(null);
const useMultiSelect = () => {
const context = useContext(MultiSelectContext);
if (!context) {
throw new Error("useMultiSelect must be used within MultiSelectProvider");
}
return context;
};
const MultiSelector = ({
values: value,
onValuesChange: onValueChange,
loop = false,
className,
children,
dir,
options,
...props
}: MultiSelectorProps) => {
const [inputValue, setInputValue] = useState("");
const [open, setOpen] = useState<boolean>(false);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const onValueChangeHandler = useCallback(
(val: string) => {
if (value.includes(val)) {
onValueChange(value.filter((item) => item !== val));
} else {
onValueChange([...value, val]);
}
},
[value]
);
// TODO : change from else if use to switch case statement
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
const moveNext = () => {
const nextIndex = activeIndex + 1;
setActiveIndex(
nextIndex > value.length - 1 ? (loop ? 0 : -1) : nextIndex
);
};
const movePrev = () => {
const prevIndex = activeIndex - 1;
setActiveIndex(prevIndex < 0 ? value.length - 1 : prevIndex);
};
if ((e.key === "Backspace" || e.key === "Delete") && value.length > 0) {
if (inputValue.length === 0) {
if (activeIndex !== -1 && activeIndex < value.length) {
onValueChange(value.filter((item) => item !== value[activeIndex]));
const newIndex = activeIndex - 1 < 0 ? 0 : activeIndex - 1;
setActiveIndex(newIndex);
} else {
onValueChange(
value.filter((item) => item !== value[value.length - 1])
);
}
}
} else if (e.key === "Enter") {
setOpen(true);
} else if (e.key === "Escape") {
if (activeIndex !== -1) {
setActiveIndex(-1);
} else {
setOpen(false);
}
} else if (dir === "rtl") {
if (e.key === "ArrowRight") {
movePrev();
} else if (e.key === "ArrowLeft" && (activeIndex !== -1 || loop)) {
moveNext();
}
} else {
if (e.key === "ArrowLeft") {
movePrev();
} else if (e.key === "ArrowRight" && (activeIndex !== -1 || loop)) {
moveNext();
}
}
},
[value, inputValue, activeIndex, loop]
);
return (
<MultiSelectContext.Provider
value={{
value,
onValueChange: onValueChangeHandler,
open,
setOpen,
inputValue,
setInputValue,
activeIndex,
setActiveIndex,
options,
}}
>
<Command
onKeyDown={handleKeyDown}
className={cn(
"overflow-visible bg-transparent flex flex-col",
className
)}
dir={dir}
{...props}
>
{children}
</Command>
</MultiSelectContext.Provider>
);
};
const MultiSelectorTrigger = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => {
const { value, onValueChange, activeIndex, open, options } = useMultiSelect();
const mousePreventDefault = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const valueOptions = options.filter(option => value.includes(option.value))
return (
<div
ref={ref}
className={cn(
"min-h-9 bg-accent text-sm flex items-center flex-wrap gap-1 px-3 rounded-lg mb-2",
open ? "ring-ring ring-1 ring-offset-1 bg-background" : "",
className
)}
{...props}
>
{valueOptions.map((item, index) => (
<Badge
key={item.value}
color={item.color}
className={cn(
"flex flex-wrap gap-1",
activeIndex === index && "ring-2 ring-muted-foreground"
)}
>
<span>{item.label}</span>
<button
aria-label={`Remove ${item.label} option`}
aria-roledescription="button to remove option"
type="button"
onMouseDown={mousePreventDefault}
onClick={() => onValueChange(item.value)}
>
<span className="sr-only">Remove {item.label} option</span>
<RemoveIcon className="h-4 w-4 hover:stroke-destructive" />
</button>
</Badge>
))}
{children}
</div>
);
});
MultiSelectorTrigger.displayName = "MultiSelectorTrigger";
const MultiSelectorInput = forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => {
const { setOpen, inputValue, setInputValue, activeIndex, setActiveIndex } =
useMultiSelect();
return (
<CommandPrimitive.Input
{...props}
ref={ref}
value={inputValue}
onValueChange={activeIndex === -1 ? setInputValue : undefined}
onBlur={() => setOpen(false)}
onFocus={() => setOpen(true)}
onClick={() => setActiveIndex(-1)}
className={cn(
"bg-transparent outline-none placeholder:text-muted-foreground flex-1",
className,
activeIndex !== -1 && "caret-transparent"
)}
/>
);
});
MultiSelectorInput.displayName = "MultiSelectorInput";
const MultiSelectorContent = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ children }, ref) => {
const { open } = useMultiSelect();
return (
<div ref={ref} className="relative">
{open && children}
</div>
);
});
MultiSelectorContent.displayName = "MultiSelectorContent";
const MultiSelectorList = forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, children }, ref) => {
return (
<CommandList
ref={ref}
className={cn(
"p-2 flex flex-col gap-2 rounded-md scrollbar-thin scrollbar-track-transparent transition-colors scrollbar-thumb-muted-foreground dark:scrollbar-thumb-muted scrollbar-thumb-rounded-lg w-full absolute bg-background shadow-md z-10 border border-muted top-0",
className
)}
>
{children}
<CommandEmpty>
<span className="text-muted-foreground">No results found</span>
</CommandEmpty>
</CommandList>
);
});
MultiSelectorList.displayName = "MultiSelectorList";
const MultiSelectorItem = forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
{ value: string } & React.ComponentPropsWithoutRef<
typeof CommandPrimitive.Item
>
>(({ className, value, children, ...props }, ref) => {
const { value: Options, onValueChange, setInputValue } = useMultiSelect();
const mousePreventDefault = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const isIncluded = Options.includes(value);
return (
<CommandItem
ref={ref}
{...props}
onSelect={() => {
onValueChange(value);
setInputValue("");
}}
className={cn(
"rounded-md cursor-pointer px-4 py-1.5 transition-colors flex justify-between ",
className,
isIncluded && "opacity-50 cursor-default",
props.disabled && "opacity-50 cursor-not-allowed"
)}
onMouseDown={mousePreventDefault}
>
{children}
{isIncluded && <Check className="h-4 w-4" />}
</CommandItem>
);
});
MultiSelectorItem.displayName = "MultiSelectorItem";
export {
MultiSelector,
MultiSelectorTrigger,
MultiSelectorInput,
MultiSelectorContent,
MultiSelectorList,
MultiSelectorItem,
};
这个组件库实现了一个多选下拉框,包含选择、显示和过滤选项等功能。组件利用了 React 的上下文、钩子和基于 Radix UI 和 CMDK 的组合控件。
以下是对每个主要部分的详细分析:
多选组件的核心上下文与状态
1. 上下文与钩子
const MultiSelectContext = createContext<MultiSelectContextProps | null>(null);
const useMultiSelect = () => {
const context = useContext(MultiSelectContext);
if (!context) {
throw new Error("useMultiSelect must be used within MultiSelectProvider");
}
return context;
};
MultiSelectContext
是一个 React 上下文,用于共享多选组件的状态。useMultiSelect
是一个自定义钩子,用于方便地访问这个上下文。
2. MultiSelector 组件
const MultiSelector = ({
values: value,
onValuesChange: onValueChange,
loop = false,
className,
children,
dir,
options,
...props
}: MultiSelectorProps) => {
const [inputValue, setInputValue] = useState("");
const [open, setOpen] = useState<boolean>(false);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const onValueChangeHandler = useCallback(
(val: string) => {
if (value.includes(val)) {
onValueChange(value.filter((item) => item !== val));
} else {
onValueChange([...value, val]);
}
},
[value]
);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
// handle keyboard navigation and actions
},
[value, inputValue, activeIndex, loop]
);
return (
<MultiSelectContext.Provider
value={{
value,
onValueChange: onValueChangeHandler,
open,
setOpen,
inputValue,
setInputValue,
activeIndex,
setActiveIndex,
options,
}}
>
<Command
onKeyDown={handleKeyDown}
className={cn(
"overflow-visible bg-transparent flex flex-col",
className
)}
dir={dir}
{...props}
>
{children}
</Command>
</MultiSelectContext.Provider>
);
};
MultiSelector
是整个多选组件的核心,负责管理状态并提供上下文。它使用 useState
管理输入值、打开状态和活动索引。通过 useCallback
创建 onValueChangeHandler
和 handleKeyDown
函数,用于处理选项的选择和键盘事件。
组件子部分
1. MultiSelectorTrigger
const MultiSelectorTrigger = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => {
const { value, onValueChange, activeIndex, open, options } = useMultiSelect();
const mousePreventDefault = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const valueOptions = options.filter(option => value.includes(option.value))
return (
<div
ref={ref}
className={cn(
"min-h-9 bg-accent text-sm flex items-center flex-wrap gap-1 px-3 rounded-lg mb-2",
open ? "ring-ring ring-1 ring-offset-1 bg-background" : "",
className
)}
{...props}
>
{valueOptions.map((item, index) => (
<Badge
key={item.value}
color={item.color}
className={cn(
"flex flex-wrap gap-1",
activeIndex === index && "ring-2 ring-muted-foreground"
)}
>
<span>{item.label}</span>
<button
aria-label={`Remove ${item.label} option`}
aria-roledescription="button to remove option"
type="button"
onMouseDown={mousePreventDefault}
onClick={() => onValueChange(item.value)}
>
<span className="sr-only">Remove {item.label} option</span>
<RemoveIcon className="h-4 w-4 hover:stroke-destructive" />
</button>
</Badge>
))}
{children}
</div>
);
});
MultiSelectorTrigger.displayName = "MultiSelectorTrigger";
MultiSelectorTrigger
是一个用于显示已选择选项的组件。它使用 useMultiSelect
钩子从上下文获取状态,并渲染已选项的 Badge
组件。每个 Badge
组件包含一个按钮,用于移除该选项。
2. MultiSelectorInput
const MultiSelectorInput = forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => {
const { setOpen, inputValue, setInputValue, activeIndex, setActiveIndex } =
useMultiSelect();
return (
<CommandPrimitive.Input
{...props}
ref={ref}
value={inputValue}
onValueChange={activeIndex === -1 ? setInputValue : undefined}
onBlur={() => setOpen(false)}
onFocus={() => setOpen(true)}
onClick={() => setActiveIndex(-1)}
className={cn(
"bg-transparent outline-none placeholder:text-muted-foreground flex-1",
className,
activeIndex !== -1 && "caret-transparent"
)}
/>
);
});
MultiSelectorInput.displayName = "MultiSelectorInput";
MultiSelectorInput
是一个输入组件,用于处理用户输入。它使用 useMultiSelect
钩子从上下文获取状态,并根据输入值更新上下文中的 inputValue
。它还处理输入框的焦点和点击事件。
3. MultiSelectorContent
const MultiSelectorContent = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ children }, ref) => {
const { open } = useMultiSelect();
return (
<div ref={ref} className="relative">
{open && children}
</div>
);
});
MultiSelectorContent.displayName = "MultiSelectorContent";
MultiSelectorContent
是一个包装组件,用于渲染多选内容。当上下文中的 open
状态为 true
时,显示其子组件。
4. MultiSelectorList
const MultiSelectorList = forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, children }, ref) => {
return (
<CommandList
ref={ref}
className={cn(
"p-2 flex flex-col gap-2 rounded-md scrollbar-thin scrollbar-track-transparent transition-colors scrollbar-thumb-muted-foreground dark:scrollbar-thumb-muted scrollbar-thumb-rounded-lg w-full absolute bg-background shadow-md z-10 border border-muted top-0",
className
)}
>
{children}
<CommandEmpty>
<span className="text-muted-foreground">No results found</span>
</CommandEmpty>
</CommandList>
);
});
MultiSelectorList.displayName = "MultiSelectorList";
MultiSelectorList
是一个列表组件,用于渲染所有可选项。它使用 CommandList
组件,并在子组件中包含一个 CommandEmpty
组件,当没有结果时显示提示。
5. MultiSelectorItem
const MultiSelectorItem = forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
{ value: string } & React.ComponentPropsWithoutRef<
typeof CommandPrimitive.Item
>
>(({ className, value, children, ...props }, ref) => {
const { value: Options, onValueChange, setInputValue } = useMultiSelect();
const mousePreventDefault = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const isIncluded = Options.includes(value);
return (
<CommandItem
ref={ref}
{...props}
onSelect={() => {
onValueChange(value);
setInputValue("");
}}
className={cn(
"rounded-md cursor-pointer px-4 py-1.5 transition-colors flex justify-between ",
className,
isIncluded && "opacity-50 cursor-default",
props.disabled && "opacity-50 cursor-not-allowed"
)}
onMouseDown={mousePreventDefault}
>
{children}
{isIncluded && <Check className="h-4 w-4" />}
</CommandItem>
);
});
MultiSelectorItem.displayName = "MultiSelectorItem";
MultiSelectorItem
是一个可选项组件。它使用 useMultiSelect
钩子从上下文获取状态,并在选中时调用 onValueChange
更新上下文中的选项状态。它还会在已选项时显示一个 Check
图标。
总结
这个多选组件库通过上下文和钩子共享状态,并将组件划分为多个小组件,每个小组件负责处理不同的功能和渲染部分。