209 lines
5.9 KiB
TypeScript
209 lines
5.9 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;
|
||
/** 提交校验失败后再为 true,用于红色错误态 */
|
||
invalid?: 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,
|
||
invalid = false,
|
||
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 showError = empty && invalid;
|
||
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${showError ? " is-invalid" : ""}`}
|
||
disabled={disabled || loading || boards.length === 0}
|
||
aria-haspopup="listbox"
|
||
aria-expanded={open}
|
||
aria-controls={listId}
|
||
aria-invalid={showError || 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>
|
||
{showError ? (
|
||
<span className="j13-board-trigger-hint">请选择板块后再发布</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>
|
||
);
|
||
}
|