Files
probably-rendering-one-more…/apps/dashboard/src/components/GuidanceUI.tsx
T

191 lines
4.6 KiB
TypeScript

import { useId, useRef, useState, type ReactNode } from "react";
import {
BookOpen,
Check,
CheckCircle2,
Copy,
FileCheck2,
FileText,
Info,
TriangleAlert,
} from "lucide-react";
import type { Guidance } from "@/content/guides/catalog";
import s from "@/styles/guidance.module.css";
export const kindNames: Record<Guidance["kind"], string> = {
guide: "操作指导",
context: "背景知识",
evidence: "事实依据",
};
export function KindIcon({
kind,
size = 18,
}: {
kind: Guidance["kind"];
size?: number;
}) {
const Icon =
kind === "guide" ? FileText : kind === "context" ? BookOpen : FileCheck2;
return <Icon size={size} aria-hidden="true" />;
}
export function KindBadge({ kind }: { kind: Guidance["kind"] }) {
return (
<span className={`${s.kindBadge} ${s[kind]}`}>
<KindIcon kind={kind} size={13} />
{kindNames[kind]}
</span>
);
}
export function CopyButton({
value,
label = "复制地址",
className = "",
}: {
value: string | (() => string);
label?: string;
className?: string;
}) {
const [state, setState] = useState<"idle" | "copied" | "error">("idle");
async function copy() {
try {
await navigator.clipboard.writeText(
typeof value === "function" ? value() : value,
);
setState("copied");
} catch {
setState("error");
}
}
return (
<span className={s.copyControl}>
<button
type="button"
className={className || s.secondaryButton}
onClick={() => void copy()}
>
{state === "copied" ? <Check size={15} /> : <Copy size={15} />}
<span>{state === "copied" ? "已复制" : label}</span>
</button>
<span className={s.srOnly} role="status">
{state === "error"
? "无法自动复制,请选择下方地址手动复制。"
: state === "copied"
? "已复制到剪贴板。"
: ""}
</span>
{state === "error" && (
<small className={s.copyError}>请手动复制下方地址</small>
)}
</span>
);
}
export function Notice({
children,
tone = "info",
}: {
children: ReactNode;
tone?: "info" | "success" | "error" | "warning";
}) {
const Icon =
tone === "error" || tone === "warning"
? TriangleAlert
: tone === "success"
? CheckCircle2
: Info;
return (
<div
className={`${s.notice} ${s["notice" + tone]}`}
role={tone === "error" ? "alert" : "status"}
>
<Icon size={18} />
<div>{children}</div>
</div>
);
}
export function PageHeading({
title,
description,
eyebrow,
children,
}: {
title: string;
description?: string;
eyebrow?: string;
children?: ReactNode;
}) {
return (
<header className={s.pageHeading}>
<div>
{eyebrow && <span className={s.eyebrow}>{eyebrow}</span>}
<h1>{title}</h1>
{description && <p>{description}</p>}
</div>
{children && <div className={s.headingActions}>{children}</div>}
</header>
);
}
export function EmptyState({
title,
description,
children,
}: {
title: string;
description: string;
children?: ReactNode;
}) {
return (
<div className={s.emptyState}>
<span className={s.emptyIcon}>
<BookOpen size={24} />
</span>
<h3>{title}</h3>
<p>{description}</p>
{children}
</div>
);
}
export function Tabs({
tabs,
value,
onChange,
}: {
tabs: { id: string; label: string; count?: number }[];
value: string;
onChange: (id: string) => void;
}) {
const group = useId(),
refs = useRef<(HTMLButtonElement | null)[]>([]);
return (
<div role="tablist" aria-label="内容视图" className={s.tabs}>
{tabs.map((tab, index) => (
<button
key={tab.id}
ref={(el) => {
refs.current[index] = el;
}}
id={`${group}-${tab.id}`}
role="tab"
type="button"
aria-selected={value === tab.id}
tabIndex={value === tab.id ? 0 : -1}
onClick={() => onChange(tab.id)}
onKeyDown={(e) => {
let next = index;
if (e.key === "ArrowRight") next = (index + 1) % tabs.length;
else if (e.key === "ArrowLeft")
next = (index - 1 + tabs.length) % tabs.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = tabs.length - 1;
else return;
e.preventDefault();
onChange(tabs[next].id);
refs.current[next]?.focus();
}}
>
{tab.label}
{tab.count !== undefined && <span>{tab.count}</span>}
</button>
))}
</div>
);
}