mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-11 11:46:37 +08:00
feat(frontend): add shadcn ui primitives and compatibility bridge
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
Accordion as BaseAccordion,
|
||||
AccordionContent,
|
||||
AccordionItem as BaseAccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface AccordionProps extends Omit<React.ComponentProps<"div">, "children"> {
|
||||
children: React.ReactNode;
|
||||
variant?: "bordered" | "light" | "splitted";
|
||||
}
|
||||
|
||||
export interface AccordionItemProps {
|
||||
"aria-label"?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
title: React.ReactNode;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export function Accordion({ children, className }: AccordionProps) {
|
||||
return (
|
||||
<BaseAccordion className={cn("w-full", className)} type="multiple">
|
||||
{children}
|
||||
</BaseAccordion>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccordionItem({
|
||||
"aria-label": ariaLabel,
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
value,
|
||||
}: AccordionItemProps) {
|
||||
const generatedValue = React.useId();
|
||||
|
||||
return (
|
||||
<BaseAccordionItem className={className} value={value ?? generatedValue}>
|
||||
<AccordionTrigger aria-label={ariaLabel}>{title}</AccordionTrigger>
|
||||
<AccordionContent>{children}</AccordionContent>
|
||||
</BaseAccordionItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
Alert as BaseAlert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
|
||||
type AlertColor = "default" | "success" | "warning" | "danger" | "primary";
|
||||
type AlertVariant = "solid" | "flat" | "faded" | "bordered";
|
||||
|
||||
interface AlertProps extends Omit<React.ComponentProps<"div">, "color" | "title"> {
|
||||
color?: AlertColor;
|
||||
description?: React.ReactNode;
|
||||
title?: React.ReactNode;
|
||||
variant?: AlertVariant;
|
||||
}
|
||||
|
||||
function mapVariant(color: AlertColor) {
|
||||
if (color === "danger") {
|
||||
return "destructive" as const;
|
||||
}
|
||||
if (color === "success") {
|
||||
return "success" as const;
|
||||
}
|
||||
if (color === "warning") {
|
||||
return "warning" as const;
|
||||
}
|
||||
|
||||
return "default" as const;
|
||||
}
|
||||
|
||||
export function Alert({
|
||||
children,
|
||||
color = "default",
|
||||
description,
|
||||
title,
|
||||
variant,
|
||||
...props
|
||||
}: AlertProps) {
|
||||
return (
|
||||
<BaseAlert
|
||||
className={variant === "flat" ? "bg-opacity-15" : undefined}
|
||||
variant={mapVariant(color)}
|
||||
{...props}
|
||||
>
|
||||
{title ? <AlertTitle>{title}</AlertTitle> : null}
|
||||
{description ? <AlertDescription>{description}</AlertDescription> : null}
|
||||
{!description && !title ? children : null}
|
||||
</BaseAlert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import * as React from "react";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
|
||||
import { Button as BaseButton } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type HeroButtonColor = "default" | "primary" | "secondary" | "success" | "warning" | "danger";
|
||||
type HeroButtonVariant = "solid" | "light" | "flat" | "ghost" | "bordered" | "shadow";
|
||||
type HeroButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface ButtonProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "color"> {
|
||||
color?: HeroButtonColor;
|
||||
endContent?: React.ReactNode;
|
||||
isIconOnly?: boolean;
|
||||
isLoading?: boolean;
|
||||
onPress?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
size?: HeroButtonSize;
|
||||
startContent?: React.ReactNode;
|
||||
variant?: HeroButtonVariant;
|
||||
}
|
||||
|
||||
function mapVariant(color: HeroButtonColor, variant: HeroButtonVariant): "default" | "destructive" | "secondary" | "outline" | "ghost" | "light" | "flat" {
|
||||
if (variant === "bordered") {
|
||||
return "outline";
|
||||
}
|
||||
if (variant === "ghost") {
|
||||
return "ghost";
|
||||
}
|
||||
if (variant === "light") {
|
||||
return "light";
|
||||
}
|
||||
if (variant === "flat") {
|
||||
return "flat";
|
||||
}
|
||||
if (color === "danger") {
|
||||
return "destructive";
|
||||
}
|
||||
if (color === "secondary") {
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
return "default";
|
||||
}
|
||||
|
||||
function mapSize(size: HeroButtonSize, isIconOnly: boolean): "default" | "sm" | "lg" | "icon" {
|
||||
if (isIconOnly) {
|
||||
return "icon";
|
||||
}
|
||||
if (size === "sm") {
|
||||
return "sm";
|
||||
}
|
||||
if (size === "lg") {
|
||||
return "lg";
|
||||
}
|
||||
|
||||
return "default";
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
className,
|
||||
color = "default",
|
||||
disabled,
|
||||
endContent,
|
||||
isIconOnly = false,
|
||||
isLoading = false,
|
||||
isDisabled,
|
||||
onClick,
|
||||
onPress,
|
||||
size = "md",
|
||||
startContent,
|
||||
type = "button",
|
||||
variant = "solid",
|
||||
...props
|
||||
}: ButtonProps & {
|
||||
isDisabled?: boolean;
|
||||
}) {
|
||||
const resolvedVariant = mapVariant(color, variant);
|
||||
const resolvedSize = mapSize(size, isIconOnly);
|
||||
const resolvedDisabled = Boolean(disabled || isDisabled || isLoading);
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
onPress?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseButton
|
||||
className={cn(isIconOnly ? "p-0" : "", className)}
|
||||
disabled={resolvedDisabled}
|
||||
size={resolvedSize}
|
||||
type={type}
|
||||
variant={resolvedVariant}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? <Loader2Icon className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{startContent}
|
||||
{isIconOnly ? null : children}
|
||||
{isIconOnly ? children : null}
|
||||
{endContent}
|
||||
</BaseButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
Card as BaseCard,
|
||||
CardContent,
|
||||
CardHeader as BaseCardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <BaseCard className={className} {...props} />;
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <BaseCardHeader className={cn("p-4 md:p-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardBody({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <CardContent className={cn("p-4 md:p-6", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Checkbox as BaseCheckbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface CheckboxProps
|
||||
extends Omit<React.ComponentProps<typeof BaseCheckbox>, "checked" | "onCheckedChange"> {
|
||||
classNames?: Record<string, string>;
|
||||
color?: string;
|
||||
isDisabled?: boolean;
|
||||
isSelected?: boolean;
|
||||
onValueChange?: (value: boolean) => void;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
children,
|
||||
className,
|
||||
isDisabled,
|
||||
isSelected,
|
||||
onValueChange,
|
||||
...props
|
||||
}: CheckboxProps & {
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const handleCheckedChange: React.ComponentProps<
|
||||
typeof BaseCheckbox
|
||||
>["onCheckedChange"] = (value: boolean | "indeterminate") => {
|
||||
onValueChange?.(value === true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("inline-flex items-center gap-2", isDisabled ? "opacity-50" : "", className)}>
|
||||
<BaseCheckbox
|
||||
checked={Boolean(isSelected)}
|
||||
disabled={isDisabled}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
{...props}
|
||||
/>
|
||||
{children ? <span className="text-sm">{children}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ChipColor = "default" | "primary" | "secondary" | "success" | "warning" | "danger";
|
||||
type ChipVariant = "solid" | "flat" | "light" | "bordered";
|
||||
type ChipSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface ChipProps extends React.ComponentProps<"span"> {
|
||||
color?: ChipColor;
|
||||
size?: ChipSize;
|
||||
variant?: ChipVariant;
|
||||
}
|
||||
|
||||
function colorVariant(color: ChipColor) {
|
||||
if (color === "danger") {
|
||||
return "destructive" as const;
|
||||
}
|
||||
if (color === "success") {
|
||||
return "success" as const;
|
||||
}
|
||||
if (color === "warning") {
|
||||
return "warning" as const;
|
||||
}
|
||||
if (color === "secondary") {
|
||||
return "secondary" as const;
|
||||
}
|
||||
|
||||
return "default" as const;
|
||||
}
|
||||
|
||||
export function Chip({
|
||||
children,
|
||||
className,
|
||||
color = "default",
|
||||
size = "md",
|
||||
variant = "solid",
|
||||
...props
|
||||
}: ChipProps) {
|
||||
return (
|
||||
<Badge
|
||||
className={cn(
|
||||
variant === "flat" || variant === "light" ? "bg-opacity-15" : "",
|
||||
variant === "bordered" ? "border-current bg-transparent" : "",
|
||||
size === "sm" ? "px-2 py-0 text-[10px]" : "",
|
||||
size === "lg" ? "px-3 py-1 text-sm" : "",
|
||||
className,
|
||||
)}
|
||||
variant={colorVariant(color)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { FieldContainer, type FieldMetaProps } from "./shared";
|
||||
|
||||
interface CalendarDateLike {
|
||||
day: number;
|
||||
month: number;
|
||||
year: number;
|
||||
}
|
||||
|
||||
export interface DatePickerProps extends FieldMetaProps {
|
||||
className?: string;
|
||||
isDisabled?: boolean;
|
||||
isRequired?: boolean;
|
||||
onChange?: (value: CalendarDateLike | null) => void;
|
||||
showMonthAndYearPickers?: boolean;
|
||||
value?: CalendarDateLike | null;
|
||||
}
|
||||
|
||||
function formatDateValue(value: CalendarDateLike | null | undefined) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
const month = String(value.month).padStart(2, "0");
|
||||
const day = String(value.day).padStart(2, "0");
|
||||
|
||||
return `${value.year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
className,
|
||||
description,
|
||||
errorMessage,
|
||||
isDisabled,
|
||||
isInvalid,
|
||||
isRequired,
|
||||
label,
|
||||
onChange,
|
||||
value,
|
||||
}: DatePickerProps) {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FieldContainer
|
||||
description={description}
|
||||
errorMessage={errorMessage}
|
||||
id={id}
|
||||
isInvalid={isInvalid}
|
||||
isRequired={isRequired}
|
||||
label={label}
|
||||
>
|
||||
<Input
|
||||
aria-invalid={isInvalid}
|
||||
className={cn(className)}
|
||||
disabled={isDisabled}
|
||||
id={id}
|
||||
required={isRequired}
|
||||
type="date"
|
||||
value={formatDateValue(value)}
|
||||
onChange={(event) => {
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.target.value) {
|
||||
onChange(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const [yearText, monthText, dayText] = event.target.value.split("-");
|
||||
const year = Number(yearText);
|
||||
const month = Number(monthText);
|
||||
const day = Number(dayText);
|
||||
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
|
||||
onChange(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onChange({ day, month, year });
|
||||
}}
|
||||
/>
|
||||
</FieldContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export function Divider(props: React.ComponentProps<typeof Separator>) {
|
||||
return <Separator {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
DropdownMenu as BaseDropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem as BaseDropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger as BaseDropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Placement = "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
||||
|
||||
interface DropdownContextValue {
|
||||
align: "start" | "end";
|
||||
}
|
||||
|
||||
const DropdownContext = React.createContext<DropdownContextValue>({ align: "start" });
|
||||
|
||||
export interface DropdownProps {
|
||||
children: React.ReactNode;
|
||||
placement?: Placement;
|
||||
}
|
||||
|
||||
export function Dropdown({ children, placement = "bottom-start" }: DropdownProps) {
|
||||
const align = placement.endsWith("end") ? "end" : "start";
|
||||
|
||||
return (
|
||||
<DropdownContext.Provider value={{ align }}>
|
||||
<BaseDropdownMenu>{children}</BaseDropdownMenu>
|
||||
</DropdownContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropdownTrigger({ children }: { children: React.ReactNode }) {
|
||||
return <BaseDropdownMenuTrigger asChild>{children}</BaseDropdownMenuTrigger>;
|
||||
}
|
||||
|
||||
export interface DropdownMenuProps {
|
||||
"aria-label"?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DropdownMenu({ children }: DropdownMenuProps) {
|
||||
const { align } = React.useContext(DropdownContext);
|
||||
|
||||
return <DropdownMenuContent align={align}>{children}</DropdownMenuContent>;
|
||||
}
|
||||
|
||||
export interface DropdownItemProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
color?: "default" | "danger";
|
||||
onPress?: () => void;
|
||||
startContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DropdownItem({
|
||||
children,
|
||||
className,
|
||||
color = "default",
|
||||
onPress,
|
||||
startContent,
|
||||
}: DropdownItemProps) {
|
||||
return (
|
||||
<BaseDropdownMenuItem
|
||||
className={cn(color === "danger" ? "text-danger focus:text-danger" : "", className)}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onPress?.();
|
||||
}}
|
||||
>
|
||||
{startContent}
|
||||
{children}
|
||||
</BaseDropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export { DropdownMenuLabel, DropdownMenuSeparator };
|
||||
@@ -0,0 +1,157 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Input as BaseInput } from "@/components/ui/input";
|
||||
import { Textarea as BaseTextarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { FieldContainer, type FieldMetaProps } from "./shared";
|
||||
|
||||
type ClassNameMap = {
|
||||
base?: string;
|
||||
description?: string;
|
||||
errorMessage?: string;
|
||||
input?: string;
|
||||
inputWrapper?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export interface InputProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
|
||||
FieldMetaProps {
|
||||
classNames?: ClassNameMap;
|
||||
endContent?: React.ReactNode;
|
||||
isDisabled?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
startContent?: React.ReactNode;
|
||||
variant?: "flat" | "bordered" | "faded" | "underlined";
|
||||
}
|
||||
|
||||
export interface TextareaProps
|
||||
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "size">,
|
||||
FieldMetaProps {
|
||||
classNames?: ClassNameMap;
|
||||
isDisabled?: boolean;
|
||||
maxRows?: number;
|
||||
minRows?: number;
|
||||
size?: "sm" | "md" | "lg";
|
||||
variant?: "flat" | "bordered" | "faded" | "underlined";
|
||||
}
|
||||
|
||||
function inputSizeClass(size: "sm" | "md" | "lg" | undefined) {
|
||||
if (size === "sm") {
|
||||
return "h-8 text-xs";
|
||||
}
|
||||
if (size === "lg") {
|
||||
return "h-10 text-base";
|
||||
}
|
||||
|
||||
return "h-9 text-sm";
|
||||
}
|
||||
|
||||
export function Input({
|
||||
className,
|
||||
classNames,
|
||||
description,
|
||||
endContent,
|
||||
errorMessage,
|
||||
id,
|
||||
isDisabled,
|
||||
isInvalid,
|
||||
isRequired,
|
||||
label,
|
||||
size,
|
||||
startContent,
|
||||
variant,
|
||||
...props
|
||||
}: InputProps) {
|
||||
const generatedId = React.useId();
|
||||
const resolvedId = id ?? generatedId;
|
||||
|
||||
return (
|
||||
<FieldContainer
|
||||
className={classNames?.base}
|
||||
description={description}
|
||||
errorMessage={errorMessage}
|
||||
id={resolvedId}
|
||||
isInvalid={isInvalid}
|
||||
isRequired={isRequired}
|
||||
label={label}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center rounded-md",
|
||||
variant === "bordered" ? "border border-input" : "",
|
||||
classNames?.inputWrapper,
|
||||
)}
|
||||
>
|
||||
{startContent ? <div className="pl-3 text-default-500">{startContent}</div> : null}
|
||||
<BaseInput
|
||||
aria-invalid={isInvalid}
|
||||
className={cn(
|
||||
inputSizeClass(size),
|
||||
variant === "bordered" ? "border-0 shadow-none" : "",
|
||||
startContent ? "pl-2" : "",
|
||||
endContent ? "pr-2" : "",
|
||||
classNames?.input,
|
||||
className,
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
id={resolvedId}
|
||||
required={isRequired}
|
||||
{...props}
|
||||
/>
|
||||
{endContent ? <div className="pr-3 text-default-500">{endContent}</div> : null}
|
||||
</div>
|
||||
</FieldContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
className,
|
||||
classNames,
|
||||
description,
|
||||
errorMessage,
|
||||
id,
|
||||
isDisabled,
|
||||
isInvalid,
|
||||
isRequired,
|
||||
label,
|
||||
maxRows,
|
||||
minRows,
|
||||
size,
|
||||
variant,
|
||||
...props
|
||||
}: TextareaProps) {
|
||||
const generatedId = React.useId();
|
||||
const resolvedId = id ?? generatedId;
|
||||
const rows = props.rows ?? minRows ?? 3;
|
||||
|
||||
return (
|
||||
<FieldContainer
|
||||
className={classNames?.base}
|
||||
description={description}
|
||||
errorMessage={errorMessage}
|
||||
id={resolvedId}
|
||||
isInvalid={isInvalid}
|
||||
isRequired={isRequired}
|
||||
label={label}
|
||||
>
|
||||
<BaseTextarea
|
||||
aria-invalid={isInvalid}
|
||||
className={cn(
|
||||
variant === "bordered" ? "border border-input" : "",
|
||||
size === "sm" ? "text-xs" : "",
|
||||
size === "lg" ? "text-base" : "",
|
||||
maxRows ? "max-h-[40vh]" : "",
|
||||
classNames?.input,
|
||||
className,
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
id={resolvedId}
|
||||
required={isRequired}
|
||||
rows={rows}
|
||||
{...props}
|
||||
/>
|
||||
</FieldContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type LinkColor = "default" | "foreground" | "primary";
|
||||
|
||||
export interface LinkProps extends React.ComponentProps<"a"> {
|
||||
color?: LinkColor;
|
||||
}
|
||||
|
||||
function mapColor(color: LinkColor) {
|
||||
if (color === "primary") {
|
||||
return "text-primary hover:text-primary/80";
|
||||
}
|
||||
if (color === "foreground") {
|
||||
return "text-foreground hover:text-foreground/80";
|
||||
}
|
||||
|
||||
return "text-default-600 hover:text-default-700";
|
||||
}
|
||||
|
||||
export function Link({ className, color = "default", ...props }: LinkProps) {
|
||||
return <a className={cn("transition-colors", mapColor(color), className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Dialog, DialogContent as BaseDialogContent } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DisclosureOptions {
|
||||
isOpen?: boolean;
|
||||
}
|
||||
|
||||
type OpenChangeHandler = (open?: boolean) => void;
|
||||
|
||||
export function useDisclosure(options: DisclosureOptions = {}) {
|
||||
const [isOpen, setIsOpen] = React.useState(Boolean(options.isOpen));
|
||||
|
||||
const onOpen = React.useCallback(() => {
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const onClose = React.useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
const onOpenChange = React.useCallback<OpenChangeHandler>((open) => {
|
||||
setIsOpen((prev) => (typeof open === "boolean" ? open : !prev));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
onClose,
|
||||
onOpen,
|
||||
onOpenChange,
|
||||
};
|
||||
}
|
||||
|
||||
interface ModalContextValue {
|
||||
classNames?: Record<string, string>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ModalContext = React.createContext<ModalContextValue | null>(null);
|
||||
|
||||
function useModalContext() {
|
||||
return React.useContext(ModalContext);
|
||||
}
|
||||
|
||||
type ModalSize = "sm" | "md" | "lg" | "xl" | "2xl" | "4xl" | "full";
|
||||
|
||||
function mapSize(size: ModalSize | undefined) {
|
||||
if (size === "sm") {
|
||||
return "max-w-md";
|
||||
}
|
||||
if (size === "lg") {
|
||||
return "max-w-2xl";
|
||||
}
|
||||
if (size === "xl") {
|
||||
return "max-w-3xl";
|
||||
}
|
||||
if (size === "2xl") {
|
||||
return "max-w-5xl";
|
||||
}
|
||||
if (size === "4xl") {
|
||||
return "max-w-6xl";
|
||||
}
|
||||
if (size === "full") {
|
||||
return "w-[95vw] max-w-[95vw] h-[95vh] max-h-[95vh]";
|
||||
}
|
||||
|
||||
return "max-w-lg";
|
||||
}
|
||||
|
||||
export interface ModalProps {
|
||||
backdrop?: "blur" | "opaque" | "transparent";
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
classNames?: Record<string, string>;
|
||||
isDismissable?: boolean;
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
placement?: "center" | "top" | "bottom";
|
||||
scrollBehavior?: "inside" | "outside";
|
||||
size?: ModalSize;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
children,
|
||||
classNames,
|
||||
isOpen = false,
|
||||
onClose,
|
||||
onOpenChange,
|
||||
}: ModalProps) {
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
onOpenChange?.(open);
|
||||
if (!open) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
const contextValue = {
|
||||
classNames,
|
||||
onClose: () => {
|
||||
handleOpenChange(false);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<ModalContext.Provider value={contextValue}>{children}</ModalContext.Provider>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModalContentProps extends Omit<React.ComponentProps<"div">, "children"> {
|
||||
children: React.ReactNode | ((onClose: () => void) => React.ReactNode);
|
||||
scrollBehavior?: "inside" | "outside";
|
||||
size?: ModalSize;
|
||||
}
|
||||
|
||||
export function ModalContent({
|
||||
children,
|
||||
className,
|
||||
scrollBehavior,
|
||||
size,
|
||||
...props
|
||||
}: ModalContentProps) {
|
||||
const context = useModalContext();
|
||||
const renderedChildren =
|
||||
typeof children === "function" ? children(() => context?.onClose()) : children;
|
||||
|
||||
return (
|
||||
<BaseDialogContent
|
||||
className={cn(
|
||||
mapSize(size),
|
||||
context?.classNames?.base,
|
||||
scrollBehavior === "outside" ? "max-h-[90vh] overflow-y-auto" : "",
|
||||
className,
|
||||
)}
|
||||
showCloseButton={false}
|
||||
{...props}
|
||||
>
|
||||
{renderedChildren}
|
||||
</BaseDialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div className={cn("text-lg font-semibold", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function ModalBody({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div className={cn("space-y-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function ModalFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type NavbarPosition = "static" | "sticky";
|
||||
type MaxWidth = "sm" | "md" | "lg" | "xl" | "2xl" | "full";
|
||||
|
||||
export interface NavbarProps extends React.ComponentProps<"nav"> {
|
||||
height?: string;
|
||||
maxWidth?: MaxWidth;
|
||||
position?: NavbarPosition;
|
||||
}
|
||||
|
||||
function maxWidthClass(maxWidth: MaxWidth | undefined) {
|
||||
if (maxWidth === "sm") {
|
||||
return "max-w-screen-sm";
|
||||
}
|
||||
if (maxWidth === "md") {
|
||||
return "max-w-screen-md";
|
||||
}
|
||||
if (maxWidth === "lg") {
|
||||
return "max-w-screen-lg";
|
||||
}
|
||||
if (maxWidth === "xl") {
|
||||
return "max-w-screen-xl";
|
||||
}
|
||||
if (maxWidth === "2xl") {
|
||||
return "max-w-screen-2xl";
|
||||
}
|
||||
if (maxWidth === "full") {
|
||||
return "max-w-none";
|
||||
}
|
||||
|
||||
return "max-w-screen-xl";
|
||||
}
|
||||
|
||||
export function Navbar({
|
||||
children,
|
||||
className,
|
||||
height,
|
||||
maxWidth = "xl",
|
||||
position = "static",
|
||||
...props
|
||||
}: NavbarProps) {
|
||||
return (
|
||||
<nav
|
||||
className={cn(
|
||||
"w-full border-b border-default-200 bg-white/90 backdrop-blur dark:bg-default-50/60",
|
||||
position === "sticky" ? "sticky top-0 z-40" : "",
|
||||
className,
|
||||
)}
|
||||
style={height ? { minHeight: height } : undefined}
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("mx-auto flex h-full w-full items-center justify-between px-4", maxWidthClass(maxWidth))}>
|
||||
{children}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export interface NavbarContentProps extends React.ComponentProps<"div"> {
|
||||
justify?: "start" | "center" | "end";
|
||||
}
|
||||
|
||||
export function NavbarContent({
|
||||
children,
|
||||
className,
|
||||
justify = "start",
|
||||
...props
|
||||
}: NavbarContentProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 items-center gap-2",
|
||||
justify === "center" ? "justify-center" : "",
|
||||
justify === "end" ? "justify-end" : "",
|
||||
justify === "start" ? "justify-start" : "",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NavbarBrand({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div className={cn("flex items-center", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Progress as BaseProgress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ProgressColor = "default" | "primary" | "secondary" | "success" | "warning" | "danger";
|
||||
|
||||
export interface ProgressProps {
|
||||
"aria-label"?: string;
|
||||
className?: string;
|
||||
color?: ProgressColor;
|
||||
label?: React.ReactNode;
|
||||
showValueLabel?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
value?: number;
|
||||
}
|
||||
|
||||
function indicatorClassName(color: ProgressColor) {
|
||||
if (color === "danger") {
|
||||
return "bg-danger";
|
||||
}
|
||||
if (color === "success") {
|
||||
return "bg-success";
|
||||
}
|
||||
if (color === "warning") {
|
||||
return "bg-warning";
|
||||
}
|
||||
|
||||
return "bg-primary";
|
||||
}
|
||||
|
||||
export function Progress({
|
||||
"aria-label": ariaLabel,
|
||||
className,
|
||||
color = "primary",
|
||||
label,
|
||||
showValueLabel,
|
||||
size = "md",
|
||||
value = 0,
|
||||
}: ProgressProps) {
|
||||
return (
|
||||
<div className={cn("w-full space-y-1", className)}>
|
||||
{label || showValueLabel ? (
|
||||
<div className="flex items-center justify-between text-xs text-default-500">
|
||||
<span>{label}</span>
|
||||
{showValueLabel ? <span>{Math.round(value)}%</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<BaseProgress
|
||||
aria-label={ariaLabel}
|
||||
className={cn(size === "sm" ? "h-1.5" : "", size === "lg" ? "h-3" : "")}
|
||||
indicatorClassName={indicatorClassName(color)}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { RadioGroup as BaseRadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface RadioContextValue {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const RadioContext = React.createContext<RadioContextValue>({ name: "radio-group" });
|
||||
|
||||
export interface RadioGroupProps {
|
||||
children: React.ReactNode;
|
||||
label?: React.ReactNode;
|
||||
onValueChange?: (value: string) => void;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export function RadioGroup({
|
||||
children,
|
||||
label,
|
||||
onValueChange,
|
||||
orientation = "vertical",
|
||||
value,
|
||||
}: RadioGroupProps) {
|
||||
const generatedName = React.useId();
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label ? <p className="text-sm font-medium">{label}</p> : null}
|
||||
<RadioContext.Provider value={{ name: generatedName }}>
|
||||
<BaseRadioGroup
|
||||
className={cn(
|
||||
orientation === "horizontal" ? "flex flex-wrap items-center gap-4" : "grid gap-3",
|
||||
)}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
>
|
||||
{children}
|
||||
</BaseRadioGroup>
|
||||
</RadioContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface RadioProps {
|
||||
children?: React.ReactNode;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function Radio({ children, value }: RadioProps) {
|
||||
const context = React.useContext(RadioContext);
|
||||
const id = `${context.name}-${value}`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem id={id} value={value} />
|
||||
<Label htmlFor={id}>{children}</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { FieldContainer, extractText, type FieldMetaProps } from "./shared";
|
||||
|
||||
type SelectionMode = "single" | "multiple";
|
||||
|
||||
type SelectionValue = Iterable<React.Key> | Set<React.Key> | Array<React.Key>;
|
||||
|
||||
interface OptionItem {
|
||||
disabled?: boolean;
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ClassNameMap {
|
||||
base?: string;
|
||||
trigger?: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
export interface SelectProps<T = unknown> extends FieldMetaProps {
|
||||
children?: React.ReactNode | ((item: T) => React.ReactNode);
|
||||
className?: string;
|
||||
classNames?: ClassNameMap;
|
||||
disabledKeys?: SelectionValue;
|
||||
isDisabled?: boolean;
|
||||
items?: Iterable<T>;
|
||||
onChange?: (event: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
onClick?: (event: React.MouseEvent<HTMLSelectElement>) => void;
|
||||
onSelectionChange?: (keys: Set<React.Key>) => void;
|
||||
placeholder?: string;
|
||||
selectedKeys?: SelectionValue;
|
||||
selectionMode?: SelectionMode;
|
||||
size?: "sm" | "md" | "lg";
|
||||
variant?: string;
|
||||
}
|
||||
|
||||
export interface SelectItemProps {
|
||||
children?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
textValue?: string;
|
||||
}
|
||||
|
||||
export function SelectItem(_props: SelectItemProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
SelectItem.displayName = "HeroSelectItem";
|
||||
|
||||
function toSet(value?: SelectionValue) {
|
||||
if (!value) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
return new Set(Array.from(value).map((item) => String(item)));
|
||||
}
|
||||
|
||||
function flattenOptionsFromNode(node: React.ReactNode, options: OptionItem[]) {
|
||||
React.Children.forEach(node, (child, index) => {
|
||||
if (child === null || child === undefined || typeof child === "boolean") {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(child)) {
|
||||
flattenOptionsFromNode(child, options);
|
||||
|
||||
return;
|
||||
}
|
||||
if (React.isValidElement(child)) {
|
||||
if (child.type === React.Fragment) {
|
||||
flattenOptionsFromNode(child.props.children, options);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (child.type === SelectItem) {
|
||||
const key = child.key ? String(child.key) : String(index);
|
||||
const props = child.props as SelectItemProps;
|
||||
|
||||
options.push({
|
||||
key,
|
||||
label: props.textValue ?? extractText(props.children) ?? key,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getOptions<T>(
|
||||
children: React.ReactNode | ((item: T) => React.ReactNode) | undefined,
|
||||
items: Iterable<T> | undefined,
|
||||
) {
|
||||
const options: OptionItem[] = [];
|
||||
|
||||
if (typeof children === "function" && items) {
|
||||
Array.from(items).forEach((item, index) => {
|
||||
const rendered = children(item);
|
||||
|
||||
if (React.isValidElement(rendered) && rendered.type === SelectItem) {
|
||||
const key = rendered.key ? String(rendered.key) : String(index);
|
||||
const props = rendered.props as SelectItemProps;
|
||||
|
||||
options.push({
|
||||
key,
|
||||
label: props.textValue ?? extractText(props.children) ?? key,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
if (typeof children !== "function") {
|
||||
flattenOptionsFromNode(children, options);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function sizeClass(size: SelectProps["size"]) {
|
||||
if (size === "sm") {
|
||||
return "h-8 text-xs";
|
||||
}
|
||||
if (size === "lg") {
|
||||
return "h-10 text-base";
|
||||
}
|
||||
|
||||
return "h-9 text-sm";
|
||||
}
|
||||
|
||||
export function Select<T>({
|
||||
children,
|
||||
className,
|
||||
classNames,
|
||||
description,
|
||||
disabledKeys,
|
||||
errorMessage,
|
||||
isDisabled,
|
||||
isInvalid,
|
||||
isRequired,
|
||||
items,
|
||||
label,
|
||||
onChange,
|
||||
onClick,
|
||||
onSelectionChange,
|
||||
placeholder,
|
||||
selectedKeys,
|
||||
selectionMode = "single",
|
||||
size,
|
||||
}: SelectProps<T>) {
|
||||
const generatedId = React.useId();
|
||||
const options = React.useMemo(() => getOptions(children, items), [children, items]);
|
||||
const selected = React.useMemo(() => toSet(selectedKeys), [selectedKeys]);
|
||||
const disabled = React.useMemo(() => toSet(disabledKeys), [disabledKeys]);
|
||||
|
||||
const selectedArray = Array.from(selected);
|
||||
const singleValue = selectedArray[0] ?? "";
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
onChange?.(event);
|
||||
|
||||
if (!onSelectionChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectionMode === "multiple") {
|
||||
const values = Array.from(event.target.selectedOptions).map((option) => option.value);
|
||||
|
||||
onSelectionChange(new Set(values));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.target.value) {
|
||||
onSelectionChange(new Set());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectionChange(new Set([event.target.value]));
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldContainer
|
||||
className={classNames?.base}
|
||||
description={description}
|
||||
errorMessage={errorMessage}
|
||||
id={generatedId}
|
||||
isInvalid={isInvalid}
|
||||
isRequired={isRequired}
|
||||
label={label}
|
||||
>
|
||||
<select
|
||||
className={cn(
|
||||
"w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
sizeClass(size),
|
||||
selectionMode === "multiple" ? "min-h-32" : "",
|
||||
classNames?.trigger,
|
||||
className,
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
id={generatedId}
|
||||
multiple={selectionMode === "multiple"}
|
||||
required={isRequired}
|
||||
value={selectionMode === "multiple" ? selectedArray : singleValue}
|
||||
onClick={onClick}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{selectionMode === "single" ? (
|
||||
<option value="">{placeholder ?? "请选择"}</option>
|
||||
) : null}
|
||||
{options.map((option) => (
|
||||
<option key={option.key} disabled={disabled.has(option.key)} value={option.key}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FieldContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface FieldMetaProps {
|
||||
label?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
errorMessage?: React.ReactNode;
|
||||
isInvalid?: boolean;
|
||||
isRequired?: boolean;
|
||||
}
|
||||
|
||||
interface FieldContainerProps extends FieldMetaProps {
|
||||
className?: string;
|
||||
id?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FieldContainer({
|
||||
children,
|
||||
className,
|
||||
description,
|
||||
errorMessage,
|
||||
id,
|
||||
isInvalid,
|
||||
isRequired,
|
||||
label,
|
||||
}: FieldContainerProps) {
|
||||
return (
|
||||
<div className={cn("w-full space-y-1.5", className)}>
|
||||
{label ? (
|
||||
<Label htmlFor={id}>
|
||||
{label}
|
||||
{isRequired ? <span className="ml-1 text-danger">*</span> : null}
|
||||
</Label>
|
||||
) : null}
|
||||
{children}
|
||||
{description ? <p className="text-xs text-default-500">{description}</p> : null}
|
||||
{isInvalid && errorMessage ? (
|
||||
<p className="text-xs text-danger">{errorMessage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function extractText(content: React.ReactNode): string {
|
||||
if (content === null || content === undefined || typeof content === "boolean") {
|
||||
return "";
|
||||
}
|
||||
if (typeof content === "string" || typeof content === "number") {
|
||||
return String(content);
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content.map((item) => extractText(item)).join("").trim();
|
||||
}
|
||||
if (React.isValidElement(content)) {
|
||||
return extractText(content.props.children);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from "react";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SpinnerSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface SpinnerProps extends React.ComponentProps<"div"> {
|
||||
label?: string;
|
||||
size?: SpinnerSize;
|
||||
}
|
||||
|
||||
function iconSize(size: SpinnerSize | undefined) {
|
||||
if (size === "sm") {
|
||||
return "h-4 w-4";
|
||||
}
|
||||
if (size === "lg") {
|
||||
return "h-7 w-7";
|
||||
}
|
||||
|
||||
return "h-5 w-5";
|
||||
}
|
||||
|
||||
export function Spinner({ className, label, size = "md", ...props }: SpinnerProps) {
|
||||
return (
|
||||
<div className={cn("inline-flex items-center gap-2", className)} {...props}>
|
||||
<Loader2Icon className={cn("animate-spin text-default-500", iconSize(size))} />
|
||||
{label ? <span className="text-sm text-default-500">{label}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Switch as BaseSwitch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SwitchProps extends Omit<React.ComponentProps<typeof BaseSwitch>, "checked" | "onCheckedChange"> {
|
||||
classNames?: Record<string, string>;
|
||||
color?: string;
|
||||
isDisabled?: boolean;
|
||||
isSelected?: boolean;
|
||||
onValueChange?: (value: boolean) => void;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export function Switch({
|
||||
className,
|
||||
isDisabled,
|
||||
isSelected,
|
||||
onValueChange,
|
||||
...props
|
||||
}: SwitchProps) {
|
||||
return (
|
||||
<BaseSwitch
|
||||
checked={Boolean(isSelected)}
|
||||
className={cn(className)}
|
||||
disabled={isDisabled}
|
||||
onCheckedChange={onValueChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface HeroUIProviderProps {
|
||||
children: React.ReactNode;
|
||||
navigate?: unknown;
|
||||
useHref?: unknown;
|
||||
}
|
||||
|
||||
export function HeroUIProvider({ children }: HeroUIProviderProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TableClassNames {
|
||||
th?: string;
|
||||
wrapper?: string;
|
||||
}
|
||||
|
||||
interface TableStyleContextValue {
|
||||
thClassName?: string;
|
||||
}
|
||||
|
||||
const TableStyleContext = React.createContext<TableStyleContextValue>({});
|
||||
|
||||
export interface TableProps extends React.ComponentProps<"table"> {
|
||||
classNames?: TableClassNames;
|
||||
}
|
||||
|
||||
export function Table({ children, className, classNames, ...props }: TableProps) {
|
||||
return (
|
||||
<TableStyleContext.Provider value={{ thClassName: classNames?.th }}>
|
||||
<div className={cn("w-full overflow-auto rounded-md", classNames?.wrapper)}>
|
||||
<table className={cn("w-full text-sm", className)} {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
</TableStyleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableHeader({ children, className, ...props }: React.ComponentProps<"thead">) {
|
||||
const childArray = React.Children.toArray(children);
|
||||
const hasRow = childArray.some(
|
||||
(child) => React.isValidElement(child) && child.type === TableRow,
|
||||
);
|
||||
|
||||
return (
|
||||
<thead className={cn("border-b bg-default-50/60", className)} {...props}>
|
||||
{hasRow ? children : <TableRow>{children}</TableRow>}
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableBodyProps<T> extends Omit<React.ComponentProps<"tbody">, "children"> {
|
||||
children?: React.ReactNode | ((item: T) => React.ReactNode);
|
||||
emptyContent?: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
items?: T[];
|
||||
loadingContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TableBody<T>({
|
||||
children,
|
||||
className,
|
||||
emptyContent,
|
||||
isLoading,
|
||||
items,
|
||||
loadingContent,
|
||||
...props
|
||||
}: TableBodyProps<T>) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<tbody className={className} {...props}>
|
||||
<tr>
|
||||
<td className="p-4 text-center text-default-500" colSpan={999}>
|
||||
{loadingContent ?? "加载中..."}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
if (items && typeof children === "function") {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<tbody className={className} {...props}>
|
||||
<tr>
|
||||
<td className="p-4 text-center text-default-500" colSpan={999}>
|
||||
{emptyContent ?? "暂无数据"}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tbody className={className} {...props}>
|
||||
{items.map((item) => {
|
||||
const key =
|
||||
typeof item === "object" && item !== null && "id" in item
|
||||
? String((item as { id: React.Key }).id)
|
||||
: JSON.stringify(item);
|
||||
|
||||
return <React.Fragment key={key}>{children(item)}</React.Fragment>;
|
||||
})}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
const staticChildren =
|
||||
typeof children === "function" ? [] : React.Children.toArray(children);
|
||||
|
||||
if (staticChildren.length === 0) {
|
||||
return (
|
||||
<tbody className={className} {...props}>
|
||||
<tr>
|
||||
<td className="p-4 text-center text-default-500" colSpan={999}>
|
||||
{emptyContent ?? "暂无数据"}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tbody className={className} {...props}>
|
||||
{typeof children === "function" ? null : children}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableColumn({ className, ...props }: React.ComponentProps<"th">) {
|
||||
const { thClassName } = React.useContext(TableStyleContext);
|
||||
|
||||
return (
|
||||
<th
|
||||
className={cn("px-3 py-2 text-left font-medium text-default-600", thClassName, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return <tr className={cn("border-b last:border-b-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return <td className={cn("px-3 py-2 align-middle", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
Tabs as BaseTabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
|
||||
interface TabDefinition {
|
||||
content: React.ReactNode;
|
||||
key: string;
|
||||
title: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface TabsProps {
|
||||
"aria-label"?: string;
|
||||
children: React.ReactNode;
|
||||
disableCursorAnimation?: boolean;
|
||||
onSelectionChange?: (key: React.Key) => void;
|
||||
selectedKey?: React.Key;
|
||||
}
|
||||
|
||||
export interface TabProps {
|
||||
children: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Tab(_props: TabProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Tab.displayName = "HeroTab";
|
||||
|
||||
function parseTabs(children: React.ReactNode) {
|
||||
const tabs: TabDefinition[] = [];
|
||||
|
||||
React.Children.forEach(children, (child, index) => {
|
||||
if (!React.isValidElement(child) || child.type !== Tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = child.key ? String(child.key) : `tab-${index}`;
|
||||
const props = child.props as TabProps;
|
||||
|
||||
tabs.push({
|
||||
content: props.children,
|
||||
key,
|
||||
title: props.title,
|
||||
});
|
||||
});
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
export function Tabs({ children, onSelectionChange, selectedKey }: TabsProps) {
|
||||
const tabs = React.useMemo(() => parseTabs(children), [children]);
|
||||
const fallback = tabs[0]?.key ?? "";
|
||||
const value = selectedKey ? String(selectedKey) : fallback;
|
||||
|
||||
return (
|
||||
<BaseTabs
|
||||
value={value}
|
||||
onValueChange={(nextValue) => {
|
||||
onSelectionChange?.(nextValue);
|
||||
}}
|
||||
>
|
||||
<TabsList
|
||||
className="grid w-full"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${Math.max(tabs.length, 1)}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{tabs.map((item) => (
|
||||
<TabsTrigger key={item.key} value={item.key}>
|
||||
{item.title}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{tabs.map((item) => (
|
||||
<TabsContent key={item.key} value={item.key}>
|
||||
{item.content}
|
||||
</TabsContent>
|
||||
))}
|
||||
</BaseTabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
|
||||
type ThemeMode = "light" | "dark";
|
||||
|
||||
const STORAGE_KEY = "flvx:theme";
|
||||
|
||||
function resolveInitialTheme(): ThemeMode {
|
||||
if (typeof window === "undefined") {
|
||||
return "light";
|
||||
}
|
||||
|
||||
const fromStorage = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (fromStorage === "dark" || fromStorage === "light") {
|
||||
return fromStorage;
|
||||
}
|
||||
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
let currentTheme: ThemeMode = resolveInitialTheme();
|
||||
const listeners = new Set<(theme: ThemeMode) => void>();
|
||||
|
||||
function broadcast(theme: ThemeMode) {
|
||||
currentTheme = theme;
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(STORAGE_KEY, theme);
|
||||
}
|
||||
listeners.forEach((listener) => {
|
||||
listener(theme);
|
||||
});
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setThemeState] = React.useState<ThemeMode>(currentTheme);
|
||||
|
||||
React.useEffect(() => {
|
||||
const listener = (nextTheme: ThemeMode) => {
|
||||
setThemeState(nextTheme);
|
||||
};
|
||||
|
||||
listeners.add(listener);
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setTheme = React.useCallback((nextTheme: string) => {
|
||||
if (nextTheme !== "dark" && nextTheme !== "light") {
|
||||
return;
|
||||
}
|
||||
broadcast(nextTheme);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
setTheme,
|
||||
theme,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user