Files
jiang13-bbs/frontend/components/BoardPicker.tsx
freefire b9ba8cb8c9 feat: 类型帖互动、积分经济与发帖体验
补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 03:17:07 +08:00

193 lines
5.3 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 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"
disabled={disabled || loading || boards.length === 0}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
onClick={() => setOpen((v) => !v)}
onKeyDown={onTriggerKeyDown}
>
<span className={`j13-board-trigger-ico av-${colorIdx}`} aria-hidden>
{(selected?.name || "板").slice(0, 1)}
</span>
<span className="flex-1 min-w-0 truncate font-semibold text-[14px]">
{label}
</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>
);
}