237 lines
7.2 KiB
TypeScript
237 lines
7.2 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { Check, Search, X } from "lucide-react";
|
|
|
|
const MONO = "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)";
|
|
|
|
/**
|
|
* 语言选择自定义下拉(统一站点样式):
|
|
* - variant="toolbar":广场工具台(含"全部语言"与清除)
|
|
* - variant="editor":新建/编辑片段表单(药丸形,语言必选)
|
|
*/
|
|
export default function SnippetLangSelect({
|
|
value,
|
|
onChange,
|
|
languages,
|
|
allowAll = true,
|
|
variant = "toolbar",
|
|
ariaLabel = "选择语言",
|
|
allLabel = "全部语言",
|
|
disabled = false,
|
|
}: {
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
languages: string[];
|
|
allowAll?: boolean;
|
|
variant?: "toolbar" | "editor";
|
|
ariaLabel?: string;
|
|
allLabel?: string;
|
|
disabled?: boolean;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [q, setQ] = useState("");
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setQ("");
|
|
const t = window.setTimeout(() => inputRef.current?.focus(), 30);
|
|
const onDown = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") setOpen(false);
|
|
};
|
|
document.addEventListener("mousedown", onDown);
|
|
document.addEventListener("keydown", onKey);
|
|
return () => {
|
|
window.clearTimeout(t);
|
|
document.removeEventListener("mousedown", onDown);
|
|
document.removeEventListener("keydown", onKey);
|
|
};
|
|
}, [open]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const kw = q.trim().toLowerCase();
|
|
return kw ? languages.filter((l) => l.includes(kw)) : languages;
|
|
}, [q, languages]);
|
|
|
|
const triggerCls =
|
|
variant === "editor"
|
|
? "j13-sn-search h-8 rounded-full pl-3 pr-2.5 text-[12.5px] gap-1.5"
|
|
: "j13-sn-search w-full h-9 rounded-lg px-3 text-[12.5px] gap-2";
|
|
const wrapCls = variant === "editor" ? "relative shrink-0" : "relative shrink-0 sm:w-44 w-full";
|
|
const menuCls =
|
|
variant === "editor"
|
|
? "j13-sn-lang-menu absolute z-30 mt-1.5 left-0 w-56 rounded-xl overflow-hidden"
|
|
: "j13-sn-lang-menu absolute z-30 mt-1.5 left-0 right-0 sm:right-auto sm:w-60 rounded-xl overflow-hidden";
|
|
|
|
return (
|
|
<div ref={ref} className={wrapCls}>
|
|
<button
|
|
type="button"
|
|
onClick={() => !disabled && setOpen((v) => !v)}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
aria-label={ariaLabel}
|
|
disabled={disabled}
|
|
className={`${triggerCls} flex items-center disabled:opacity-60 disabled:cursor-not-allowed`}
|
|
style={{ background: "var(--panel)", border: "1px solid transparent", color: "var(--ink)" }}
|
|
>
|
|
<span
|
|
className="w-[6px] h-[6px] rounded-full shrink-0"
|
|
style={{ background: value ? "var(--accent)" : "var(--ink-3)", opacity: value ? 1 : 0.5 }}
|
|
/>
|
|
<span
|
|
className="truncate text-left flex-1"
|
|
style={{ fontFamily: MONO, color: value ? "var(--ink)" : "var(--ink-3)" }}
|
|
>
|
|
{value || allLabel}
|
|
</span>
|
|
{allowAll && value ? (
|
|
<span
|
|
role="button"
|
|
tabIndex={-1}
|
|
aria-label="清除语言筛选"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onChange("");
|
|
}}
|
|
className="w-5 h-5 -mr-1 flex items-center justify-center rounded-md hover:bg-[var(--panel-2)] transition-colors"
|
|
style={{ color: "var(--ink-3)" }}
|
|
>
|
|
<X size={11} />
|
|
</span>
|
|
) : (
|
|
<ArrowDownIcon className="shrink-0" rotated={open} />
|
|
)}
|
|
</button>
|
|
|
|
{open && (
|
|
<div
|
|
className={menuCls}
|
|
style={{
|
|
background: "var(--panel)",
|
|
border: "1px solid var(--line-2)",
|
|
boxShadow: "var(--shadow-lg)",
|
|
}}
|
|
role="listbox"
|
|
>
|
|
<div className="p-2" style={{ borderBottom: "1px solid var(--line)" }}>
|
|
<div className="relative">
|
|
<Search
|
|
size={12}
|
|
className="absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none"
|
|
style={{ color: "var(--ink-3)" }}
|
|
/>
|
|
<input
|
|
ref={inputRef}
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
placeholder="筛选语言…"
|
|
aria-label="筛选语言"
|
|
className="w-full h-8 rounded-md pl-7 pr-2 text-[12px] outline-none"
|
|
style={{ background: "var(--panel-2)", border: "1px solid transparent", color: "var(--ink)" }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<ul className="max-h-56 overflow-y-auto py-1">
|
|
{allowAll ? (
|
|
<LangOption
|
|
active={value === ""}
|
|
onClick={() => {
|
|
onChange("");
|
|
setOpen(false);
|
|
}}
|
|
label={allLabel}
|
|
dotDim
|
|
/>
|
|
) : null}
|
|
{filtered.length === 0 ? (
|
|
<li className="px-3 py-3 text-center text-[12px]" style={{ color: "var(--ink-3)" }}>
|
|
没有匹配的语言
|
|
</li>
|
|
) : (
|
|
filtered.map((l) => (
|
|
<LangOption
|
|
key={l}
|
|
active={value === l}
|
|
onClick={() => {
|
|
onChange(l);
|
|
setOpen(false);
|
|
}}
|
|
label={l}
|
|
mono
|
|
/>
|
|
))
|
|
)}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ArrowDownIcon({ className = "", rotated }: { className?: string; rotated: boolean }) {
|
|
return (
|
|
<svg
|
|
width="12"
|
|
height="12"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2.2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={`${className} ${rotated ? "rotate-180" : ""}`}
|
|
style={{ color: "var(--ink-3)", transition: "transform 160ms ease" }}
|
|
aria-hidden
|
|
>
|
|
<path d="m6 9 6 6 6-6" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function LangOption({
|
|
active,
|
|
onClick,
|
|
label,
|
|
mono,
|
|
dotDim,
|
|
}: {
|
|
active: boolean;
|
|
onClick: () => void;
|
|
label: string;
|
|
mono?: boolean;
|
|
dotDim?: boolean;
|
|
}) {
|
|
return (
|
|
<li>
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
aria-selected={active}
|
|
onClick={onClick}
|
|
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-[12.5px] transition-colors hover:bg-[var(--panel-2)]"
|
|
style={{
|
|
color: active ? "var(--accent)" : "var(--ink-2)",
|
|
background: active ? "var(--accent-soft)" : undefined,
|
|
fontFamily: mono ? MONO : undefined,
|
|
}}
|
|
>
|
|
<span
|
|
className="w-[5px] h-[5px] rounded-full shrink-0"
|
|
style={{
|
|
background: active ? "var(--accent)" : "var(--ink-3)",
|
|
opacity: active ? 1 : dotDim ? 0.5 : 0.3,
|
|
}}
|
|
/>
|
|
<span className="flex-1 truncate">{label}</span>
|
|
{active ? <Check size={12} className="shrink-0" /> : null}
|
|
</button>
|
|
</li>
|
|
);
|
|
}
|