Skip to content

Commit fa14a98

Browse files
Merge pull request #2 from jkemmererupgrade/feature/lucide-icon-picker
Add Lucide icon picker to node styling dialog
2 parents 89910a2 + c16b6d7 commit fa14a98

6 files changed

Lines changed: 263 additions & 1 deletion

File tree

Changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- Add defaultStyling.json support for persistent per-type vertex and edge
66
styling (#1265, #112, #173, #573, #689)
7+
- Add Lucide icon picker to node styling dialog
78

89
## Release 3.0.0
910

docs/references/default-styling.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,10 @@ labels must exactly match the vertex/edge type names in your graph database.
9191

9292
### Icons
9393

94-
There are two ways to specify vertex icons:
94+
There are three ways to set vertex icons:
9595

96+
- **Icon Picker (UI)** — In the Node Style dialog, click **Browse** to search
97+
and select from the full Lucide icon library (~1,900 icons).
9698
- **`icon`** — A [Lucide](https://lucide.dev/icons) icon name in kebab-case
9799
(e.g., `"user"`, `"log-in"`, `"landmark"`). Resolved to an SVG at runtime. No
98100
additional files needed.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { render, screen, waitFor } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
4+
import { IconPicker } from "./IconPicker";
5+
6+
describe("IconPicker", () => {
7+
it("should render Browse button", () => {
8+
render(<IconPicker onSelect={vi.fn()} />);
9+
expect(screen.getByRole("button", { name: /browse/i })).toBeInTheDocument();
10+
});
11+
12+
it("should open popover with search input on click", async () => {
13+
const user = userEvent.setup();
14+
render(<IconPicker onSelect={vi.fn()} />);
15+
16+
await user.click(screen.getByRole("button", { name: /browse/i }));
17+
18+
expect(screen.getByPlaceholderText("Search icons...")).toBeInTheDocument();
19+
});
20+
21+
it("should show icons in the grid", async () => {
22+
const user = userEvent.setup();
23+
render(<IconPicker onSelect={vi.fn()} />);
24+
25+
await user.click(screen.getByRole("button", { name: /browse/i }));
26+
27+
// Wait for at least some icon buttons to appear in the grid
28+
await waitFor(() => {
29+
const iconButtons = screen
30+
.getAllByRole("button")
31+
.filter(btn => btn.title && btn.title !== "");
32+
expect(iconButtons.length).toBeGreaterThan(0);
33+
});
34+
});
35+
36+
it("should filter icons when searching", async () => {
37+
const user = userEvent.setup();
38+
render(<IconPicker onSelect={vi.fn()} />);
39+
40+
await user.click(screen.getByRole("button", { name: /browse/i }));
41+
const searchInput = screen.getByPlaceholderText("Search icons...");
42+
43+
await user.type(searchInput, "user");
44+
45+
await waitFor(() => {
46+
const iconButtons = screen
47+
.getAllByRole("button")
48+
.filter(btn => btn.title && btn.title.includes("user"));
49+
expect(iconButtons.length).toBeGreaterThan(0);
50+
});
51+
});
52+
53+
it("should show no results message for invalid search", async () => {
54+
const user = userEvent.setup();
55+
render(<IconPicker onSelect={vi.fn()} />);
56+
57+
await user.click(screen.getByRole("button", { name: /browse/i }));
58+
const searchInput = screen.getByPlaceholderText("Search icons...");
59+
60+
await user.type(searchInput, "zzzznotanicon");
61+
62+
expect(screen.getByText("No icons found")).toBeInTheDocument();
63+
});
64+
65+
it("should call onSelect with data URI when icon is clicked", async () => {
66+
const user = userEvent.setup();
67+
const onSelect = vi.fn();
68+
render(<IconPicker onSelect={onSelect} />);
69+
70+
await user.click(screen.getByRole("button", { name: /browse/i }));
71+
72+
// Wait for icons to load then click the first one
73+
await waitFor(() => {
74+
const iconButtons = screen
75+
.getAllByRole("button")
76+
.filter(btn => btn.title && btn.title !== "");
77+
expect(iconButtons.length).toBeGreaterThan(0);
78+
});
79+
80+
const firstIcon = screen
81+
.getAllByRole("button")
82+
.filter(btn => btn.title && btn.title !== "")[0];
83+
await user.click(firstIcon);
84+
85+
await waitFor(() => {
86+
expect(onSelect).toHaveBeenCalledWith(
87+
expect.stringMatching(/^data:image\/svg\+xml;base64,/),
88+
"image/svg+xml",
89+
);
90+
});
91+
});
92+
93+
it("should close popover after selecting an icon", async () => {
94+
const user = userEvent.setup();
95+
render(<IconPicker onSelect={vi.fn()} />);
96+
97+
await user.click(screen.getByRole("button", { name: /browse/i }));
98+
99+
await waitFor(() => {
100+
const iconButtons = screen
101+
.getAllByRole("button")
102+
.filter(btn => btn.title && btn.title !== "");
103+
expect(iconButtons.length).toBeGreaterThan(0);
104+
});
105+
106+
const firstIcon = screen
107+
.getAllByRole("button")
108+
.filter(btn => btn.title && btn.title !== "")[0];
109+
await user.click(firstIcon);
110+
111+
await waitFor(() => {
112+
expect(
113+
screen.queryByPlaceholderText("Search icons..."),
114+
).not.toBeInTheDocument();
115+
});
116+
});
117+
});
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { SearchIcon } from "lucide-react";
2+
import dynamicIconImports from "lucide-react/dynamicIconImports";
3+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4+
5+
import { lucideIconToDataUri } from "@/utils/lucideIconUrl";
6+
7+
import { Button, Input, Popover, PopoverContent, PopoverTrigger } from ".";
8+
9+
const allIconNames = Object.keys(dynamicIconImports).sort();
10+
11+
const MAX_VISIBLE = 50;
12+
13+
export function IconPicker({
14+
onSelect,
15+
}: {
16+
onSelect: (iconUrl: string, iconImageType: string) => void;
17+
}) {
18+
const [open, setOpen] = useState(false);
19+
const [search, setSearch] = useState("");
20+
const inputRef = useRef<HTMLInputElement>(null);
21+
22+
const filtered = useMemo(() => {
23+
if (!search) return allIconNames.slice(0, MAX_VISIBLE);
24+
const lower = search.toLowerCase();
25+
const results: string[] = [];
26+
for (const name of allIconNames) {
27+
if (name.includes(lower)) {
28+
results.push(name);
29+
if (results.length >= MAX_VISIBLE) break;
30+
}
31+
}
32+
return results;
33+
}, [search]);
34+
35+
const handleSelect = useCallback(
36+
async (iconName: string) => {
37+
const dataUri = await lucideIconToDataUri(iconName);
38+
if (dataUri) {
39+
onSelect(dataUri, "image/svg+xml");
40+
setOpen(false);
41+
setSearch("");
42+
}
43+
},
44+
[onSelect],
45+
);
46+
47+
// Focus search input when popover opens
48+
useEffect(() => {
49+
if (open) {
50+
// Small delay to allow popover animation
51+
const timer = setTimeout(() => inputRef.current?.focus(), 100);
52+
return () => clearTimeout(timer);
53+
}
54+
}, [open]);
55+
56+
return (
57+
<Popover open={open} onOpenChange={setOpen}>
58+
<PopoverTrigger asChild>
59+
<Button variant="outline" className="rounded-full">
60+
<SearchIcon className="size-4" />
61+
Browse
62+
</Button>
63+
</PopoverTrigger>
64+
<PopoverContent
65+
side="bottom"
66+
align="start"
67+
className="flex w-80 flex-col gap-2 p-3"
68+
>
69+
<Input
70+
ref={inputRef}
71+
placeholder="Search icons..."
72+
value={search}
73+
onChange={e => setSearch(e.target.value)}
74+
className="h-8 text-sm"
75+
/>
76+
<div className="grid max-h-60 grid-cols-8 gap-1 overflow-y-auto">
77+
{filtered.map(name => (
78+
<IconButton key={name} name={name} onSelect={handleSelect} />
79+
))}
80+
{filtered.length === 0 && (
81+
<p className="text-text-secondary col-span-8 py-4 text-center text-sm">
82+
No icons found
83+
</p>
84+
)}
85+
</div>
86+
{!search && (
87+
<p className="text-text-secondary text-xs">
88+
Showing {MAX_VISIBLE} of {allIconNames.length} icons. Type to
89+
search.
90+
</p>
91+
)}
92+
</PopoverContent>
93+
</Popover>
94+
);
95+
}
96+
97+
function IconButton({
98+
name,
99+
onSelect,
100+
}: {
101+
name: string;
102+
onSelect: (name: string) => void;
103+
}) {
104+
const [src, setSrc] = useState<string | null>(null);
105+
106+
useEffect(() => {
107+
let cancelled = false;
108+
lucideIconToDataUri(name).then(
109+
uri => {
110+
if (!cancelled && uri) setSrc(uri);
111+
},
112+
() => {
113+
// Icon failed to load, leave as placeholder
114+
},
115+
);
116+
return () => {
117+
cancelled = true;
118+
};
119+
}, [name]);
120+
121+
return (
122+
<button
123+
type="button"
124+
title={name}
125+
className="hover:bg-background-contrast-secondary flex size-8 items-center justify-center rounded"
126+
onClick={() => onSelect(name)}
127+
>
128+
{src ? (
129+
<img src={src} alt={name} className="size-5" />
130+
) : (
131+
<div className="bg-background-contrast-secondary size-5 animate-pulse rounded" />
132+
)}
133+
</button>
134+
);
135+
}

packages/graph-explorer/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export * from "./Form";
3636
export * from "./numberFormat";
3737

3838
export * from "./icons";
39+
export * from "./IconPicker";
3940

4041
export * from "./Input";
4142
export { default as InputField } from "./InputField";

packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
FieldLabel,
1111
FieldSet,
1212
FileButton,
13+
IconPicker,
1314
Input,
1415
Select,
1516
SelectContent,
@@ -208,6 +209,11 @@ function Content({ vertexType }: { vertexType: VertexType }) {
208209
<Field>
209210
<FieldLabel>Icon</FieldLabel>
210211
<div className="flex flex-row items-center gap-2">
212+
<IconPicker
213+
onSelect={(iconUrl, iconImageType) =>
214+
setVertexStyle({ iconUrl, iconImageType })
215+
}
216+
/>
211217
<FileButton
212218
accept="image/*"
213219
onChange={file => {

0 commit comments

Comments
 (0)