Files
Endoleap/src/components/canvas/icon-picker.tsx
T

77 lines
6.4 KiB
TypeScript

/**
* 图标选择面板。按来源、分类与文本查询筛选目录,使用固定五列虚拟列表只挂载视窗附近的图标按钮。
* 仅保存搜索/滚动/焦点状态;choose把名称和标签交给画布创建节点,实际SVG按需加载。
*/
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { CanvasIcon } from "./canvas-icon";
import { iconCatalog, iconCategories, searchIcons, type IconSource } from "./icon-catalog";
import styles from "./canvas.module.css";
/** 固定网格参数:每行五项、每行48px;占位总高与绝对定位必须使用同一组参数。 */
const columns = 5, rowHeight = 48;
/** 通过Portal挂载到body,调用方在浏览器中打开;Escape或关闭按钮只触发close。 */
export function IconPicker({ close, choose }: { close: () => void; choose: (name: string, label: string) => void }) {
const [query, setQuery] = useState("");
const [category, setCategory] = useState("common");
const [source, setSource] = useState<IconSource>("all");
const [scrollTop, setScrollTop] = useState(0);
const [height, setHeight] = useState(240);
const [focused, setFocused] = useState(0);
const search = useRef<HTMLInputElement>(null);
const grid = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
// 有非空查询时搜索当前来源的全部分类;无查询才使用分类按钮值,useMemo避免无关渲染重复过滤。
const matches = useMemo(() => searchIcons(query, query.trim() ? "all" : category, source), [query, category, source]);
// 挂载时聚焦搜索框并观察列表实际高度;卸载断开观察器,尝试把焦点还给打开前的元素。
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
search.current?.focus();
const observer = new ResizeObserver(([entry]) => setHeight(entry.contentRect.height));
if (grid.current) observer.observe(grid.current);
return () => { observer.disconnect(); previous?.focus(); };
}, []);
// 任一筛选条件变化都回到列表顶部与首项焦点索引,避免沿用上次结果中的越界位置。
useEffect(() => { if (grid.current) grid.current.scrollTop = 0; setScrollTop(0); setFocused(0); }, [query, category, source]);
// 根据总行数撑起滚动区域,上下各多挂载一行缓冲;slice只取可见范围的目录项。
const rows = Math.ceil(matches.length / columns);
const firstRow = Math.max(0, Math.floor(scrollTop / rowHeight) - 1);
const lastRow = Math.min(rows, Math.ceil((scrollTop + height) / rowHeight) + 1);
const visible = matches.slice(firstRow * columns, lastRow * columns);
/** 先钳制目标索引,再按行把目标滚入视口;等下一帧虚拟按钮挂载后调用focus。
* 空结果没有可聚焦按钮,最终可选链自然不执行focus;不凭空创建占位项目。 */
function focusIcon(index: number) {
const next = Math.max(0, Math.min(matches.length - 1, index));
setFocused(next);
const top = Math.floor(next / columns) * rowHeight;
if (grid.current) {
if (top < grid.current.scrollTop) grid.current.scrollTop = top;
else if (top + rowHeight > grid.current.scrollTop + height) grid.current.scrollTop = top + rowHeight - height;
setScrollTop(grid.current.scrollTop);
}
requestAnimationFrame(() => grid.current?.querySelector<HTMLButtonElement>(`[data-index="${next}"]`)?.focus());
}
// 分类点击同时清空搜索,来源选择保留查询;网格按钮按全局结果索引绝对定位,不按切片局部索引。
// 左右移动一项、上下移动一行,Home/End跳至结果首尾;只有当前focused项进入Tab顺序。
// 每个按钮由CanvasIcon加载路径,页脚结果数来自筛选数组;choose后是否关闭由调用方决定。
return createPortal(<motion.aside initial={{ opacity: 0, x: reduce ? 0 : -12 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: reduce ? 0 : 0.16 }} className={styles.iconPicker} role="dialog" aria-label="添加图标" onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Escape") close(); }}>
<header><strong>图标</strong><button aria-label="关闭图标面板" onClick={close}><X size={16} /></button></header>
<label className={styles.iconSource}>图标库<select aria-label="图标库" value={source} onChange={(event) => setSource(event.target.value as IconSource)}><option value="all">全部图标库</option><option value="lucide">Lucide · 2313</option><option value="tabler">Tabler · 24 个试用</option></select></label>
<input ref={search} type="search" aria-label="搜索图标" placeholder="搜索当前库的全部图标…" value={query} onChange={(event) => setQuery(event.target.value)} />
<div className={styles.iconCategories} aria-label="图标分类">{iconCategories.map(([id, label]) => <button key={id} aria-pressed={!query.trim() && category === id} onClick={() => { setCategory(id); setQuery(""); }}>{label}</button>)}</div>
<div ref={grid} className={styles.virtualIconGrid} aria-label="图标列表" onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}>
<div style={{ height: rows * rowHeight, position: "relative" }}>{visible.map(({ name, label, source }, offset) => {
// 加回首行偏移取得完整结果索引,保证滚动后的定位、Tab状态与键盘导航仍对应同一条记录。
const index = firstRow * columns + offset;
return <button key={name} type="button" data-index={index} tabIndex={focused === index ? 0 : -1} title={`${label} · ${source === "tabler" ? "Tabler" : "Lucide"}`} aria-label={`添加${label}图标`} style={{ position: "absolute", width: "20%", height: rowHeight, left: `${index % columns * 20}%`, top: Math.floor(index / columns) * rowHeight }} onFocus={() => setFocused(index)} onClick={() => choose(name, label)}
onKeyDown={(event) => { const delta = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: columns, ArrowUp: -columns, Home: -index, End: matches.length - 1 - index }[event.key]; if (delta !== undefined) { event.preventDefault(); focusIcon(index + delta); } }}><CanvasIcon name={name} size={24} /></button>;
})}</div>
</div>
{!matches.length && <p>未找到图标</p>}
<footer>{matches.length} 个结果 · 共 {iconCatalog.length} 个 · 随滚动加载</footer>
</motion.aside>, document.body);
}