diff --git a/Changelog.md b/Changelog.md index dbb4b4dfa..e9b9a5916 100644 --- a/Changelog.md +++ b/Changelog.md @@ -4,6 +4,7 @@ - Add defaultStyling.json support for persistent per-type vertex and edge styling (#1265, #112, #173, #573, #689) +- Add Lucide icon picker to node styling dialog ## Release 3.0.0 diff --git a/docs/references/default-styling.md b/docs/references/default-styling.md index 0775e1c36..ebcce86b8 100644 --- a/docs/references/default-styling.md +++ b/docs/references/default-styling.md @@ -91,8 +91,10 @@ labels must exactly match the vertex/edge type names in your graph database. ### Icons -There are two ways to specify vertex icons: +There are three ways to set vertex icons: +- **Icon Picker (UI)** — In the Node Style dialog, click **Browse** to search + and select from the full Lucide icon library (~1,900 icons). - **`icon`** — A [Lucide](https://lucide.dev/icons) icon name in kebab-case (e.g., `"user"`, `"log-in"`, `"landmark"`). Resolved to an SVG at runtime. No additional files needed. diff --git a/packages/graph-explorer/src/components/IconPicker.test.tsx b/packages/graph-explorer/src/components/IconPicker.test.tsx new file mode 100644 index 000000000..e78314887 --- /dev/null +++ b/packages/graph-explorer/src/components/IconPicker.test.tsx @@ -0,0 +1,117 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { IconPicker } from "./IconPicker"; + +describe("IconPicker", () => { + it("should render Browse button", () => { + render(); + expect(screen.getByRole("button", { name: /browse/i })).toBeInTheDocument(); + }); + + it("should open popover with search input on click", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + expect(screen.getByPlaceholderText("Search icons...")).toBeInTheDocument(); + }); + + it("should show icons in the grid", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + // Wait for at least some icon buttons to appear in the grid + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== ""); + expect(iconButtons.length).toBeGreaterThan(0); + }); + }); + + it("should filter icons when searching", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + const searchInput = screen.getByPlaceholderText("Search icons..."); + + await user.type(searchInput, "user"); + + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title.includes("user")); + expect(iconButtons.length).toBeGreaterThan(0); + }); + }); + + it("should show no results message for invalid search", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + const searchInput = screen.getByPlaceholderText("Search icons..."); + + await user.type(searchInput, "zzzznotanicon"); + + expect(screen.getByText("No icons found")).toBeInTheDocument(); + }); + + it("should call onSelect with data URI when icon is clicked", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + // Wait for icons to load then click the first one + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== ""); + expect(iconButtons.length).toBeGreaterThan(0); + }); + + const firstIcon = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== "")[0]; + await user.click(firstIcon); + + await waitFor(() => { + expect(onSelect).toHaveBeenCalledWith( + expect.stringMatching(/^data:image\/svg\+xml;base64,/), + "image/svg+xml", + ); + }); + }); + + it("should close popover after selecting an icon", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== ""); + expect(iconButtons.length).toBeGreaterThan(0); + }); + + const firstIcon = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== "")[0]; + await user.click(firstIcon); + + await waitFor(() => { + expect( + screen.queryByPlaceholderText("Search icons..."), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/graph-explorer/src/components/IconPicker.tsx b/packages/graph-explorer/src/components/IconPicker.tsx new file mode 100644 index 000000000..f8b6f852c --- /dev/null +++ b/packages/graph-explorer/src/components/IconPicker.tsx @@ -0,0 +1,135 @@ +import { SearchIcon } from "lucide-react"; +import dynamicIconImports from "lucide-react/dynamicIconImports"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { lucideIconToDataUri } from "@/utils/lucideIconUrl"; + +import { Button, Input, Popover, PopoverContent, PopoverTrigger } from "."; + +const allIconNames = Object.keys(dynamicIconImports).sort(); + +const MAX_VISIBLE = 50; + +export function IconPicker({ + onSelect, +}: { + onSelect: (iconUrl: string, iconImageType: string) => void; +}) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const inputRef = useRef(null); + + const filtered = useMemo(() => { + if (!search) return allIconNames.slice(0, MAX_VISIBLE); + const lower = search.toLowerCase(); + const results: string[] = []; + for (const name of allIconNames) { + if (name.includes(lower)) { + results.push(name); + if (results.length >= MAX_VISIBLE) break; + } + } + return results; + }, [search]); + + const handleSelect = useCallback( + async (iconName: string) => { + const dataUri = await lucideIconToDataUri(iconName); + if (dataUri) { + onSelect(dataUri, "image/svg+xml"); + setOpen(false); + setSearch(""); + } + }, + [onSelect], + ); + + // Focus search input when popover opens + useEffect(() => { + if (open) { + // Small delay to allow popover animation + const timer = setTimeout(() => inputRef.current?.focus(), 100); + return () => clearTimeout(timer); + } + }, [open]); + + return ( + + + + + + setSearch(e.target.value)} + className="h-8 text-sm" + /> +
+ {filtered.map(name => ( + + ))} + {filtered.length === 0 && ( +

+ No icons found +

+ )} +
+ {!search && ( +

+ Showing {MAX_VISIBLE} of {allIconNames.length} icons. Type to + search. +

+ )} +
+
+ ); +} + +function IconButton({ + name, + onSelect, +}: { + name: string; + onSelect: (name: string) => void; +}) { + const [src, setSrc] = useState(null); + + useEffect(() => { + let cancelled = false; + lucideIconToDataUri(name).then( + uri => { + if (!cancelled && uri) setSrc(uri); + }, + () => { + // Icon failed to load, leave as placeholder + }, + ); + return () => { + cancelled = true; + }; + }, [name]); + + return ( + + ); +} diff --git a/packages/graph-explorer/src/components/index.ts b/packages/graph-explorer/src/components/index.ts index 2bc9878eb..4343b7aec 100644 --- a/packages/graph-explorer/src/components/index.ts +++ b/packages/graph-explorer/src/components/index.ts @@ -36,6 +36,7 @@ export * from "./Form"; export * from "./numberFormat"; export * from "./icons"; +export * from "./IconPicker"; export * from "./Input"; export { default as InputField } from "./InputField"; diff --git a/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx b/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx index 4dd74e54e..905a14764 100644 --- a/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx +++ b/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx @@ -10,6 +10,7 @@ import { FieldLabel, FieldSet, FileButton, + IconPicker, Input, Select, SelectContent, @@ -208,6 +209,11 @@ function Content({ vertexType }: { vertexType: VertexType }) { Icon
+ + setVertexStyle({ iconUrl, iconImageType }) + } + /> {