@allbluecn/web-app 0.11.4 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,37 +17,65 @@
17
17
 
18
18
  import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
19
19
  import { Search } from "lucide-react";
20
+ import { useVirtualizer } from "@tanstack/react-virtual";
20
21
  import { cn } from "@/lib/utils";
21
22
  import { Input } from "@/components/ui/input";
22
- import { CURATED_ICONS } from "./curated-icons";
23
+ import { CURATED_SEARCH_INDEX, PICKER_SEARCH_INDEX } from "./icon-set";
24
+ import {
25
+ LAB_ICONS_BY_CATEGORY,
26
+ LAB_PRIORITY_CATEGORIES,
27
+ type LabCategory,
28
+ } from "./lab-icons";
23
29
  import {
24
- buildSearchIndex,
25
30
  filterIcons,
26
31
  getRecentIcons,
27
32
  pruneRecentIcons,
33
+ type CuratedIcon,
28
34
  type IconSearchEntry,
29
35
  } from "./icon-data";
30
36
  import { m } from "@/paraglide/messages.js";
31
37
 
32
- const SEARCH_INDEX = buildSearchIndex(CURATED_ICONS);
38
+ const PANEL_WIDTH = 22 * 16;
39
+ const PANEL_HEIGHT = 26.25 * 16;
40
+ const GRID_COLS = 8;
41
+ const CELL_SIZE = 36;
42
+ const CELL_GAP = 4;
43
+ const ROW_HEIGHT = CELL_SIZE + CELL_GAP;
44
+ const HEADER_HEIGHT = 24;
45
+ const OVERSCAN = 8;
33
46
 
34
47
  interface IconPickerPanelProps {
35
48
  value?: string;
36
49
  onPick: (name: string) => void;
37
50
  }
38
51
 
39
- function IconGrid({
40
- items,
52
+ type PanelBlock =
53
+ | { kind: "header"; key: string; title: string }
54
+ | { kind: "row"; key: string; icons: CuratedIcon[] };
55
+
56
+ const LAB_CATEGORY_TITLES: Record<LabCategory, () => string> = {
57
+ "food-beverage": () => m.common_iconPicker_categoryFoodBeverage(),
58
+ gaming: () => m.common_iconPicker_categoryGaming(),
59
+ home: () => m.common_iconPicker_categoryHome(),
60
+ animals: () => m.common_iconPicker_categoryAnimals(),
61
+ nature: () => m.common_iconPicker_categoryNature(),
62
+ people: () => m.common_iconPicker_categoryPeople(),
63
+ seasons: () => m.common_iconPicker_categorySeasons(),
64
+ other: () => m.common_iconPicker_labMore(),
65
+ };
66
+
67
+ function IconRow({
68
+ icons,
41
69
  value,
42
70
  onPick,
43
71
  }: {
44
- items: IconSearchEntry[];
72
+ icons: CuratedIcon[];
45
73
  value?: string;
46
74
  onPick: (name: string) => void;
47
75
  }) {
48
76
  return (
49
- <div className="grid grid-cols-8 gap-1 px-2 pb-2">
50
- {items.map((item) => {
77
+ <div className="grid grid-cols-8 gap-1 px-2" style={{ height: ROW_HEIGHT }}>
78
+ {icons.map((item) => {
51
79
  const Icon = item.Icon;
52
80
  const selected = item.name === value;
53
81
  return (
@@ -59,7 +87,8 @@ function IconGrid({
59
87
  onClick={() => onPick(item.name)}
60
88
  className={cn(
61
89
  "flex size-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
62
- selected && "bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary"
90
+ selected &&
91
+ "bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary"
63
92
  )}
64
93
  >
65
94
  <Icon size={18} aria-hidden />
@@ -70,38 +99,137 @@ function IconGrid({
70
99
  );
71
100
  }
72
101
 
73
- function SectionTitle({ children, className }: { children: ReactNode; className?: string }) {
102
+ function SectionTitle({
103
+ children,
104
+ className,
105
+ }: {
106
+ children: ReactNode;
107
+ className?: string;
108
+ }) {
74
109
  return (
75
- <h3 className={cn("px-3 pb-1 text-xs font-medium text-muted-foreground", className)}>
110
+ <h3
111
+ className={cn(
112
+ "px-3 pt-2 pb-1 text-xs font-medium leading-none text-muted-foreground",
113
+ className
114
+ )}
115
+ style={{ height: HEADER_HEIGHT }}
116
+ >
76
117
  {children}
77
118
  </h3>
78
119
  );
79
120
  }
80
121
 
122
+ function buildBlocks(
123
+ sections: { id: string; title: string; icons: CuratedIcon[] }[]
124
+ ): PanelBlock[] {
125
+ const blocks: PanelBlock[] = [];
126
+ for (const section of sections) {
127
+ if (section.icons.length === 0) continue;
128
+ blocks.push({
129
+ kind: "header",
130
+ key: `header-${section.id}`,
131
+ title: section.title,
132
+ });
133
+ for (let start = 0; start < section.icons.length; start += GRID_COLS) {
134
+ blocks.push({
135
+ kind: "row",
136
+ key: `row-${section.id}-${start / GRID_COLS}`,
137
+ icons: section.icons.slice(start, start + GRID_COLS),
138
+ });
139
+ }
140
+ }
141
+ return blocks;
142
+ }
143
+
81
144
  export function IconPickerPanel({ value, onPick }: IconPickerPanelProps) {
82
145
  const [query, setQuery] = useState("");
146
+ const [viewportHeight, setViewportHeight] = useState<number | null>(null);
83
147
  const searchRef = useRef<HTMLInputElement>(null);
148
+ const scrollRef = useRef<HTMLDivElement>(null);
84
149
 
85
150
  useEffect(() => {
86
151
  searchRef.current?.focus();
87
152
  // 清理最近使用中已失效的记录(如精选名单收缩前存入的图标名)
88
153
  const stored = getRecentIcons();
89
- const invalid = stored.filter((n) => !SEARCH_INDEX.some((e) => e.name === n));
90
- if (invalid.length > 0) pruneRecentIcons(new Set(stored.filter((n) => !invalid.includes(n))));
154
+ const invalid = stored.filter((n) => !PICKER_SEARCH_INDEX.some((e) => e.name === n));
155
+ if (invalid.length > 0)
156
+ pruneRecentIcons(new Set(stored.filter((n) => !invalid.includes(n))));
157
+ }, []);
158
+
159
+ useEffect(() => {
160
+ const el = scrollRef.current;
161
+ if (!el) return;
162
+ const update = () => setViewportHeight(el.clientHeight);
163
+ update();
164
+ window.addEventListener("resize", update);
165
+ return () => window.removeEventListener("resize", update);
91
166
  }, []);
92
167
 
93
168
  const searching = query.trim().length > 0;
94
169
  const results = useMemo(
95
- () => (searching ? filterIcons(query.trim(), SEARCH_INDEX) : []),
170
+ () => (searching ? filterIcons(query.trim(), PICKER_SEARCH_INDEX) : []),
96
171
  [query, searching]
97
172
  );
173
+
98
174
  const recent = getRecentIcons()
99
- .map((n) => SEARCH_INDEX.find((e) => e.name === n))
175
+ .map((n) => PICKER_SEARCH_INDEX.find((e) => e.name === n))
100
176
  .filter((e): e is IconSearchEntry => Boolean(e));
101
- const firstVisible = searching ? results[0] : (recent[0] ?? SEARCH_INDEX[0]);
177
+
178
+ const sections = searching
179
+ ? [
180
+ {
181
+ id: "results",
182
+ title: m.common_iconPicker_resultCount({ count: results.length }),
183
+ icons: results,
184
+ },
185
+ ]
186
+ : [
187
+ ...(recent.length > 0
188
+ ? [{ id: "recent", title: m.common_iconPicker_recent(), icons: recent }]
189
+ : []),
190
+ ...LAB_PRIORITY_CATEGORIES.map((category) => ({
191
+ id: `lab-${category}`,
192
+ title: LAB_CATEGORY_TITLES[category](),
193
+ icons: LAB_ICONS_BY_CATEGORY[category],
194
+ })),
195
+ {
196
+ id: "lab-other",
197
+ title: m.common_iconPicker_labMore(),
198
+ icons: LAB_ICONS_BY_CATEGORY.other,
199
+ },
200
+ {
201
+ id: "curated",
202
+ title: m.common_iconPicker_standard(),
203
+ icons: CURATED_SEARCH_INDEX,
204
+ },
205
+ ];
206
+
207
+ const blocks = buildBlocks(sections);
208
+ const firstVisible = searching ? results[0] : (recent[0] ?? PICKER_SEARCH_INDEX[0]);
209
+
210
+ // 视口高度未知(首次渲染)或大于 0 时走虚拟化;高度为 0(jsdom 等无布局环境)降级为整表渲染
211
+ const virtualized = viewportHeight === null || viewportHeight > 0;
212
+
213
+ const virtualizer = useVirtualizer({
214
+ count: blocks.length,
215
+ getScrollElement: () => scrollRef.current,
216
+ estimateSize: (index) =>
217
+ blocks[index].kind === "header" ? HEADER_HEIGHT : ROW_HEIGHT,
218
+ overscan: OVERSCAN,
219
+ });
220
+
221
+ const renderBlock = (block: PanelBlock) =>
222
+ block.kind === "header" ? (
223
+ <SectionTitle key={block.key}>{block.title}</SectionTitle>
224
+ ) : (
225
+ <IconRow key={block.key} icons={block.icons} value={value} onPick={onPick} />
226
+ );
102
227
 
103
228
  return (
104
- <div className="flex flex-col overflow-auto" style={{ height: 26.25 * 16, width: 22 * 16 }}>
229
+ <div
230
+ className="flex flex-col overflow-hidden"
231
+ style={{ width: PANEL_WIDTH, height: PANEL_HEIGHT }}
232
+ >
105
233
  {/* 搜索框 */}
106
234
  <div className="relative shrink-0 p-1.5 pb-2">
107
235
  <Search
@@ -125,35 +253,33 @@ export function IconPickerPanel({ value, onPick }: IconPickerPanelProps) {
125
253
  />
126
254
  </div>
127
255
 
128
- {searching ? (
129
- <>
130
- <div className="shrink-0 px-3 pb-1 text-xs text-muted-foreground">
131
- {m.common_iconPicker_resultCount({ count: results.length })}
132
- </div>
133
- {results.length === 0 ? (
134
- <div className="flex flex-1 items-center justify-center px-6 text-center text-xs text-muted-foreground">
135
- {m.common_iconPicker_noResults()}
256
+ {searching && results.length === 0 ? (
257
+ <div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center text-xs text-muted-foreground">
258
+ {m.common_iconPicker_noResults()}
259
+ </div>
260
+ ) : (
261
+ <div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
262
+ {virtualized ? (
263
+ <div style={{ position: "relative", height: virtualizer.getTotalSize() }}>
264
+ {virtualizer.getVirtualItems().map((item) => (
265
+ <div
266
+ key={item.key}
267
+ style={{
268
+ position: "absolute",
269
+ top: item.start,
270
+ left: 0,
271
+ width: "100%",
272
+ height: item.size,
273
+ }}
274
+ >
275
+ {renderBlock(blocks[item.index])}
276
+ </div>
277
+ ))}
136
278
  </div>
137
279
  ) : (
138
- <IconGrid items={results} value={value} onPick={onPick} />
139
- )}
140
- </>
141
- ) : (
142
- <>
143
- {/* 最近使用:仅在有记录时显示,与全部图标同页上下排列 */}
144
- {recent.length > 0 && (
145
- <section>
146
- <SectionTitle>{m.common_iconPicker_recent()}</SectionTitle>
147
- <IconGrid items={recent} value={value} onPick={onPick} />
148
- </section>
280
+ <div>{blocks.map(renderBlock)}</div>
149
281
  )}
150
- <section>
151
- <SectionTitle className={cn(recent.length === 0 && "pt-1")}>
152
- {m.common_iconPicker_all()}
153
- </SectionTitle>
154
- <IconGrid items={SEARCH_INDEX} value={value} onPick={onPick} />
155
- </section>
156
- </>
282
+ </div>
157
283
  )}
158
284
  </div>
159
285
  );
@@ -95,8 +95,8 @@ export const SDK_GROUPS: SdkGroup[] = [
95
95
  "items": [
96
96
  {
97
97
  "name": "@allbluecn/web-app",
98
- "version": "v0.11.4",
99
- "lastUpdate": "Sep 12, 2026",
98
+ "version": "v0.12.1",
99
+ "lastUpdate": "Sep 13, 2026",
100
100
  "source": "apps/web/package.json",
101
101
  "docsUrl": "https://github.com/allbluecn/allblue"
102
102
  },