后台可注入全站 CSS/JS(SSR 生效,CSS 可热换);发帖/编辑改为锁一屏工作室;经济看板增加存量健康度;主题令牌与弹层误关一并收紧。 Co-authored-by: Cursor <cursoragent@cursor.com>
203 lines
5.7 KiB
TypeScript
203 lines
5.7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useId, useRef, useState } from "react";
|
|
import { Check, ChevronDown } from "lucide-react";
|
|
import type { Board } from "@/lib/api";
|
|
|
|
type BoardPickerProps = {
|
|
boards: Board[];
|
|
value: number;
|
|
onChange: (id: number) => void;
|
|
disabled?: boolean;
|
|
loading?: boolean;
|
|
id?: string;
|
|
};
|
|
|
|
function boardColorIndex(b: Board, fallbackIndex: number) {
|
|
return Math.max(0, b.color_index ?? fallbackIndex) % 8;
|
|
}
|
|
|
|
export default function BoardPicker({
|
|
boards,
|
|
value,
|
|
onChange,
|
|
disabled,
|
|
loading,
|
|
id,
|
|
}: BoardPickerProps) {
|
|
const listId = useId();
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
const [open, setOpen] = useState(false);
|
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
|
|
const selectedIdx = boards.findIndex((b) => b.id === value);
|
|
const selected = selectedIdx >= 0 ? boards[selectedIdx] : null;
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setActiveIndex(selectedIdx >= 0 ? selectedIdx : 0);
|
|
}, [open, selectedIdx]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onDoc = (e: MouseEvent) => {
|
|
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
setOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", onDoc);
|
|
document.addEventListener("keydown", onKey);
|
|
return () => {
|
|
document.removeEventListener("mousedown", onDoc);
|
|
document.removeEventListener("keydown", onKey);
|
|
};
|
|
}, [open]);
|
|
|
|
const pick = (id: number) => {
|
|
onChange(id);
|
|
setOpen(false);
|
|
};
|
|
|
|
const onTriggerKeyDown = (e: React.KeyboardEvent) => {
|
|
if (disabled || loading) return;
|
|
if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
setOpen(true);
|
|
}
|
|
};
|
|
|
|
const onMenuKeyDown = (e: React.KeyboardEvent) => {
|
|
if (!boards.length) return;
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setActiveIndex((i) => (i + 1) % boards.length);
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setActiveIndex((i) => (i - 1 + boards.length) % boards.length);
|
|
} else if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
const b = boards[activeIndex];
|
|
if (b) pick(b.id);
|
|
} else if (e.key === "Home") {
|
|
e.preventDefault();
|
|
setActiveIndex(0);
|
|
} else if (e.key === "End") {
|
|
e.preventDefault();
|
|
setActiveIndex(boards.length - 1);
|
|
}
|
|
};
|
|
|
|
const empty = !loading && !selected;
|
|
const label = loading
|
|
? "加载板块…"
|
|
: selected
|
|
? selected.name
|
|
: boards.length === 0
|
|
? "暂无可用板块"
|
|
: "请选择板块";
|
|
|
|
const colorIdx = selected
|
|
? boardColorIndex(selected, selectedIdx)
|
|
: 0;
|
|
|
|
return (
|
|
<div className="j13-board-picker" ref={rootRef}>
|
|
<button
|
|
id={id}
|
|
type="button"
|
|
className={`j13-board-trigger${empty ? " is-empty" : ""}`}
|
|
disabled={disabled || loading || boards.length === 0}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
aria-controls={listId}
|
|
aria-invalid={empty || undefined}
|
|
onClick={() => setOpen((v) => !v)}
|
|
onKeyDown={onTriggerKeyDown}
|
|
>
|
|
<span
|
|
className={`j13-board-trigger-ico${selected ? ` av-${colorIdx}` : " is-placeholder"}`}
|
|
aria-hidden
|
|
>
|
|
{(selected?.name || "板").slice(0, 1)}
|
|
</span>
|
|
<span className="j13-board-trigger-copy">
|
|
<span className="j13-board-trigger-name">{label}</span>
|
|
{empty ? (
|
|
<span className="j13-board-trigger-hint">发布前必须选择</span>
|
|
) : loading ? (
|
|
<span className="j13-board-trigger-hint">正在加载</span>
|
|
) : null}
|
|
</span>
|
|
<ChevronDown
|
|
size={16}
|
|
className="shrink-0 opacity-60"
|
|
style={{
|
|
color: "var(--ink-3)",
|
|
transform: open ? "rotate(180deg)" : undefined,
|
|
transition: "transform 0.15s ease",
|
|
}}
|
|
/>
|
|
</button>
|
|
|
|
{open && (
|
|
<ul
|
|
id={listId}
|
|
role="listbox"
|
|
aria-label="选择板块"
|
|
className="j13-board-menu"
|
|
tabIndex={-1}
|
|
onKeyDown={onMenuKeyDown}
|
|
>
|
|
{boards.map((b, i) => {
|
|
const selectedHere = b.id === value;
|
|
const c = boardColorIndex(b, i);
|
|
return (
|
|
<li key={b.id} role="presentation">
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
aria-selected={selectedHere}
|
|
className={`j13-board-option${
|
|
selectedHere ? " is-selected" : ""
|
|
}${i === activeIndex ? " is-active" : ""}`}
|
|
onMouseEnter={() => setActiveIndex(i)}
|
|
onClick={() => pick(b.id)}
|
|
>
|
|
<span className={`j13-board-trigger-ico av-${c}`} aria-hidden>
|
|
{b.name.slice(0, 1)}
|
|
</span>
|
|
<span className="flex-1 min-w-0 truncate">{b.name}</span>
|
|
{selectedHere && <Check size={15} className="shrink-0" />}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 编辑态只读板块徽章(与触发器同高) */
|
|
export function BoardBadge({
|
|
name,
|
|
colorIndex = 0,
|
|
}: {
|
|
name: string;
|
|
colorIndex?: number;
|
|
}) {
|
|
const c = Math.max(0, colorIndex) % 8;
|
|
return (
|
|
<div className="j13-board-badge">
|
|
<span className={`j13-board-trigger-ico av-${c}`} aria-hidden>
|
|
{name.slice(0, 1)}
|
|
</span>
|
|
<span className="truncate">{name}</span>
|
|
</div>
|
|
);
|
|
}
|