feat: redesign responsive console and fix restore regressions
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { DialogRoot, DialogContent, DialogFooter } from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { buttonVariants } from './ui/button';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export interface ConfirmOptions {
|
||||
title: string;
|
||||
body?: ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
danger?: boolean;
|
||||
/** When set, the confirm button stays disabled until this exact text is typed. */
|
||||
requireText?: string;
|
||||
requireTextLabel?: string;
|
||||
}
|
||||
|
||||
interface ConfirmRequest {
|
||||
options: ConfirmOptions;
|
||||
resolve: (ok: boolean) => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({request, onClose}: {request: ConfirmRequest | undefined; onClose: (ok: boolean) => void}) {
|
||||
const [typed, setTyped] = useState('');
|
||||
useEffect(() => { setTyped(''); }, [request]);
|
||||
const options = request?.options;
|
||||
const needsText = !!options?.requireText;
|
||||
const confirmed = !needsText || typed === options?.requireText;
|
||||
return <DialogRoot open={!!request} onOpenChange={open => { if (!open) onClose(false); }}>
|
||||
{options && <DialogContent title={options.title} onOpenAutoFocus={e => { if (!needsText) return; e.preventDefault(); }}>
|
||||
{options.body && <div className="mb-4 text-[13px] leading-relaxed text-muted-foreground wrap-anywhere">{options.body}</div>}
|
||||
{needsText && <label className="mb-1 grid gap-1.5 text-[13px] font-medium">
|
||||
{options.requireTextLabel || '输入完整标识以确认'}
|
||||
<Input value={typed} onChange={e => setTyped(e.target.value)} autoFocus autoComplete="off" spellCheck={false} className="font-mono" placeholder={options.requireText}/>
|
||||
</label>}
|
||||
<DialogFooter>
|
||||
<button type="button" className={cn(buttonVariants({variant: 'outline'}))} onClick={() => onClose(false)}>{options.cancelLabel || '取消'}</button>
|
||||
<button type="button" disabled={!confirmed} className={cn(buttonVariants({variant: options.danger ? 'destructive' : 'default'}))} onClick={() => onClose(true)}>{options.confirmLabel || '确认'}</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>}
|
||||
</DialogRoot>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise-based replacement for window.confirm / window.prompt(confirm-id).
|
||||
* Render {confirm.element} once near the page root; ask() resolves true/false.
|
||||
* Plain functions (no useCallback) keep it usable in bare vnode-tree tests.
|
||||
*/
|
||||
export function useConfirm() {
|
||||
const [request, setRequest] = useState<ConfirmRequest>();
|
||||
// A ref tracks the live request so back-to-back ask() calls (e.g. a fast
|
||||
// double submit before re-render) cancel the previous promise instead of
|
||||
// leaking it. Direct value sets keep bare vnode-tree tests working.
|
||||
const current = useRef<ConfirmRequest | undefined>(undefined);
|
||||
useEffect(() => () => {
|
||||
current.current?.resolve(false);
|
||||
current.current = undefined;
|
||||
}, []);
|
||||
const ask = (options: ConfirmOptions) => new Promise<boolean>(resolve => {
|
||||
current.current?.resolve(false);
|
||||
const next: ConfirmRequest = {options, resolve};
|
||||
current.current = next;
|
||||
setRequest(next);
|
||||
});
|
||||
const close = (ok: boolean) => {
|
||||
current.current?.resolve(ok);
|
||||
current.current = undefined;
|
||||
setRequest(undefined);
|
||||
};
|
||||
return {ask, element: <ConfirmDialog request={request} onClose={close}/>};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { AlertCircle, Info, type LucideIcon } from "lucide-react";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const alertVariants = cva("flex items-start gap-2.5 rounded-lg border px-3.5 py-3 text-[13px] leading-relaxed [&_svg]:mt-0.5 [&_svg]:size-4 [&_svg]:shrink-0", {
|
||||
variants: {
|
||||
variant: {
|
||||
destructive: "border-destructive/30 bg-destructive-muted text-destructive",
|
||||
info: "border-primary/25 bg-secondary/60 text-secondary-foreground",
|
||||
neutral: "border-border bg-muted/70 text-muted-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "neutral" },
|
||||
});
|
||||
|
||||
const icons: Record<string, LucideIcon> = { destructive: AlertCircle, info: Info, neutral: Info };
|
||||
|
||||
export interface AlertProps extends HTMLAttributes<HTMLDivElement>, VariantProps<typeof alertVariants> {
|
||||
hideIcon?: boolean;
|
||||
}
|
||||
|
||||
export function AlertBox({ className, variant = "neutral", hideIcon = false, children, ...props }: AlertProps) {
|
||||
const Icon = icons[variant || "neutral"];
|
||||
return (
|
||||
<div role={variant === "destructive" ? "alert" : "status"} className={cn(alertVariants({ variant }), className)} {...props}>
|
||||
{!hideIcon && <Icon aria-hidden />}
|
||||
<div className="min-w-0 flex-1 wrap-anywhere">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap before:size-1.5 before:rounded-full before:bg-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted text-muted-foreground",
|
||||
success: "bg-success-muted text-success",
|
||||
danger: "bg-destructive-muted text-destructive",
|
||||
warning: "bg-warning-muted text-warning",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
export function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes } 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 gap-1.5 whitespace-nowrap rounded-md font-medium transition-colors cursor-pointer select-none disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary-hover",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/70",
|
||||
outline: "border border-border bg-card text-foreground shadow-sm hover:bg-muted",
|
||||
ghost: "text-foreground hover:bg-muted",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
"destructive-ghost": "text-destructive border border-border bg-card hover:bg-destructive-muted hover:border-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
// Pixel sizes remain correct with the application's 14px root font.
|
||||
default: "h-[44px] md:h-[36px] px-4 text-sm",
|
||||
sm: "h-[44px] md:h-[32px] px-3 text-[13px]",
|
||||
lg: "h-[44px] px-5 text-[15px]",
|
||||
icon: "h-[44px] w-[44px] md:h-[36px] md:w-[36px] p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default", size: "default" },
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ className, variant, size, asChild = false, ...props },
|
||||
ref,
|
||||
) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return <Comp ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />;
|
||||
});
|
||||
|
||||
export { buttonVariants };
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export function Card({ className, ...props }: HTMLAttributes<HTMLElement>) {
|
||||
return <section className={cn("rounded-xl border border-border bg-card text-card-foreground shadow-card", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-wrap items-center justify-between gap-3 px-4 pt-4 pb-3 sm:px-5", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h2 className={cn("text-[15px] font-semibold leading-none", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cn("text-[13px] text-muted-foreground", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("px-4 pb-4 sm:px-5 sm:pb-5", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex items-center gap-2 border-t border-border px-4 py-3 sm:px-5", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { useRef, type ComponentProps, type ReactNode } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export const DialogRoot = DialogPrimitive.Root;
|
||||
export const DialogTrigger = DialogPrimitive.Trigger;
|
||||
export const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
export function DialogContent({
|
||||
className,
|
||||
children,
|
||||
title,
|
||||
onOpenAutoFocus,
|
||||
onCloseAutoFocus,
|
||||
...props
|
||||
}: ComponentProps<typeof DialogPrimitive.Content> & { title: string; children: ReactNode }) {
|
||||
const opener = useRef<HTMLElement | null>(null);
|
||||
return (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-nav/60 backdrop-blur-[2px] data-[state=open]:animate-[overlay-in_0.15s_ease-out]" />
|
||||
<DialogPrimitive.Content
|
||||
aria-describedby={undefined}
|
||||
onOpenAutoFocus={event => {
|
||||
opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
onOpenAutoFocus?.(event);
|
||||
}}
|
||||
onCloseAutoFocus={event => {
|
||||
onCloseAutoFocus?.(event);
|
||||
if (!event.defaultPrevented && opener.current?.isConnected) {
|
||||
event.preventDefault();
|
||||
opener.current.focus();
|
||||
}
|
||||
}}
|
||||
aria-busy={props["aria-busy"]}
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 z-50 w-[min(30rem,calc(100vw-1.5rem))] max-h-[calc(100dvh-1.5rem)] -translate-x-1/2 -translate-y-1/2 overflow-auto rounded-xl border border-border bg-card p-5 text-card-foreground shadow-dialog data-[state=open]:animate-[dialog-in_0.18s_ease-out]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<DialogPrimitive.Title className="text-base font-semibold wrap-anywhere">{title}</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Close disabled={props['aria-busy'] === true} className="grid size-[44px] shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring" aria-label="关闭对话框">
|
||||
<X className="size-4" aria-hidden />
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogFooter({ className, ...props }: ComponentProps<"div">) {
|
||||
return <div className={cn("mt-5 flex flex-col-reverse gap-2 border-t border-border pt-4 sm:flex-row sm:justify-end", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as DropdownPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import type { ComponentProps } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export const DropdownMenu = DropdownPrimitive.Root;
|
||||
export const DropdownMenuTrigger = DropdownPrimitive.Trigger;
|
||||
|
||||
export function DropdownMenuContent({ className, ...props }: ComponentProps<typeof DropdownPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownPrimitive.Portal>
|
||||
<DropdownPrimitive.Content
|
||||
sideOffset={6}
|
||||
className={cn("z-50 min-w-44 rounded-lg border border-border bg-card p-1 text-card-foreground shadow-dialog data-[state=open]:animate-[overlay-in_0.12s_ease-out]", className)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropdownMenuItem({ className, ...props }: ComponentProps<typeof DropdownPrimitive.Item>) {
|
||||
return (
|
||||
<DropdownPrimitive.Item
|
||||
className={cn(
|
||||
"flex cursor-pointer select-none items-center gap-2 rounded-md px-2.5 py-2.5 sm:py-2 text-[13px] outline-none transition-colors data-[highlighted]:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes, type SelectHTMLAttributes, type CSSProperties } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const fieldClass =
|
||||
"w-full min-w-0 rounded-md border border-input bg-card px-3 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground/70 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-60 aria-invalid:border-destructive aria-invalid:outline-destructive";
|
||||
|
||||
/** Class constants for raw <input>/<select>/<textarea> elements.
|
||||
* Pages prefer these over the wrapper components so vnode-tree tests keep
|
||||
* matching native element types. */
|
||||
export const inputClass = cn(fieldClass, "h-[44px] md:h-[36px]");
|
||||
export const textareaClass = cn(fieldClass, "py-2 min-h-20");
|
||||
export const selectClass = cn(fieldClass, "h-[44px] md:h-[36px] appearance-none pr-8 bg-no-repeat bg-[right_0.6rem_center] bg-[length:14px]");
|
||||
|
||||
export const selectStyle: CSSProperties = {
|
||||
backgroundImage:
|
||||
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23647d88' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E\")",
|
||||
};
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(function Input({ className, ...props }, ref) {
|
||||
return <input ref={ref} className={cn(inputClass, className)} {...props} />;
|
||||
});
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(function Textarea({ className, ...props }, ref) {
|
||||
return <textarea ref={ref} className={cn(textareaClass, className)} {...props} />;
|
||||
});
|
||||
|
||||
/** Native select: keeps the OS picker on phones, styled to match inputs. */
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSelectElement>>(function Select({ className, style, ...props }, ref) {
|
||||
return <select ref={ref} className={cn(selectClass, className)} style={{...selectStyle, ...style}} {...props} />;
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { LabelHTMLAttributes, ReactNode } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export function FieldLabel({ className, children, ...props }: LabelHTMLAttributes<HTMLLabelElement> & { children: ReactNode }) {
|
||||
return (
|
||||
<label className={cn("grid gap-1.5 text-[13px] font-medium text-foreground", className)} {...props}>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import type { ComponentProps } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export const Tabs = TabsPrimitive.Root;
|
||||
|
||||
export function TabsList({ className, ...props }: ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn("inline-flex w-full max-w-full gap-1 overflow-x-auto rounded-lg bg-muted p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabsTrigger({ className, ...props }: ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
className={cn(
|
||||
"shrink-0 cursor-pointer rounded-md px-3 py-2 sm:py-1.5 text-[13px] font-medium whitespace-nowrap text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabsContent({ className, ...props }: ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return <TabsPrimitive.Content className={cn("mt-4 outline-none", className)} {...props} />;
|
||||
}
|
||||
Reference in New Issue
Block a user