Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion docs/references/default-styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
117 changes: 117 additions & 0 deletions packages/graph-explorer/src/components/IconPicker.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<IconPicker onSelect={vi.fn()} />);
expect(screen.getByRole("button", { name: /browse/i })).toBeInTheDocument();
});

it("should open popover with search input on click", async () => {
const user = userEvent.setup();
render(<IconPicker onSelect={vi.fn()} />);

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(<IconPicker onSelect={vi.fn()} />);

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(<IconPicker onSelect={vi.fn()} />);

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(<IconPicker onSelect={vi.fn()} />);

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(<IconPicker onSelect={onSelect} />);

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(<IconPicker onSelect={vi.fn()} />);

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();
});
});
});
135 changes: 135 additions & 0 deletions packages/graph-explorer/src/components/IconPicker.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="rounded-full">
<SearchIcon className="size-4" />
Browse
</Button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
className="flex w-80 flex-col gap-2 p-3"
>
<Input
ref={inputRef}
placeholder="Search icons..."
value={search}
onChange={e => setSearch(e.target.value)}
className="h-8 text-sm"
/>
<div className="grid max-h-60 grid-cols-8 gap-1 overflow-y-auto">
{filtered.map(name => (
<IconButton key={name} name={name} onSelect={handleSelect} />
))}
{filtered.length === 0 && (
<p className="text-text-secondary col-span-8 py-4 text-center text-sm">
No icons found
</p>
)}
</div>
{!search && (
<p className="text-text-secondary text-xs">
Showing {MAX_VISIBLE} of {allIconNames.length} icons. Type to
search.
</p>
)}
</PopoverContent>
</Popover>
);
}

function IconButton({
name,
onSelect,
}: {
name: string;
onSelect: (name: string) => void;
}) {
const [src, setSrc] = useState<string | null>(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 (
<button
type="button"
title={name}
className="hover:bg-background-contrast-secondary flex size-8 items-center justify-center rounded"
onClick={() => onSelect(name)}
>
{src ? (
<img src={src} alt={name} className="size-5" />
) : (
<div className="bg-background-contrast-secondary size-5 animate-pulse rounded" />
)}
</button>
);
}
1 change: 1 addition & 0 deletions packages/graph-explorer/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
FieldLabel,
FieldSet,
FileButton,
IconPicker,
Input,
Select,
SelectContent,
Expand Down Expand Up @@ -208,6 +209,11 @@ function Content({ vertexType }: { vertexType: VertexType }) {
<Field>
<FieldLabel>Icon</FieldLabel>
<div className="flex flex-row items-center gap-2">
<IconPicker
onSelect={(iconUrl, iconImageType) =>
setVertexStyle({ iconUrl, iconImageType })
}
/>
<FileButton
accept="image/*"
onChange={file => {
Expand Down