191 lines
4.6 KiB
TypeScript
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>
|
|
);
|
|
}
|