-
Notifications
You must be signed in to change notification settings - Fork 91
Add built-in Lucide icon library for node styling #1777
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
kmcginnes
merged 6 commits into
aws:main
from
jkemmererupgrade:feature/lucide-icon-picker
Jun 3, 2026
Merged
Changes from 1 commit
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
ddb455f
feat(icons): add built-in Lucide icon library for node styling
jkemmererupgrade 22d1acc
address PR review feedback
jkemmererupgrade 913df88
Remove dead code, consolidate Lucide icon utilities, fix edge cases
kmcginnes 9b13735
Merge remote-tracking branch 'origin/main' into feature/lucide-icon-p…
kmcginnes a25604f
Revert unrelated lint-staged config change in root package.json
kmcginnes aa08a35
Fix lint and type errors in lucideIcons utilities
kmcginnes File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -107,7 +107,6 @@ | |
| }, | ||
| "lint-staged": { | ||
| "*.{ts,tsx}": [ | ||
| "eslint --fix", | ||
| "oxfmt" | ||
| ] | ||
| }, | ||
|
|
||
153 changes: 153 additions & 0 deletions
153
packages/graph-explorer/src/components/IconPicker.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| // @vitest-environment happy-dom | ||
| 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 lucide:<name> reference 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 })); | ||
|
|
||
| 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]; | ||
| const iconName = firstIcon.getAttribute("title"); | ||
| await user.click(firstIcon); | ||
|
|
||
| expect(onSelect).toHaveBeenCalledWith( | ||
| `lucide:${iconName}`, | ||
| "image/svg+xml", | ||
| ); | ||
| }); | ||
|
|
||
| it("should highlight the icon matching currentIconUrl", async () => { | ||
| const user = userEvent.setup(); | ||
| render(<IconPicker currentIconUrl="lucide:airplay" onSelect={vi.fn()} />); | ||
|
|
||
| await user.click(screen.getByRole("button", { name: /browse/i })); | ||
|
|
||
| await waitFor(() => { | ||
| const airplayBtn = screen | ||
| .getAllByRole("button") | ||
| .find(btn => btn.title === "airplay"); | ||
| expect(airplayBtn).toBeDefined(); | ||
| expect(airplayBtn).toHaveAttribute("aria-pressed", "true"); | ||
| }); | ||
| }); | ||
|
|
||
| it("should not highlight any icon when currentIconUrl is not a lucide ref", async () => { | ||
| const user = userEvent.setup(); | ||
| render( | ||
| <IconPicker | ||
| currentIconUrl="data:image/svg+xml;base64,XXXX" | ||
| 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); | ||
| for (const btn of iconButtons) { | ||
| expect(btn).toHaveAttribute("aria-pressed", "false"); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| 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(); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,157 @@ | ||||||
| import { SearchIcon } from "lucide-react"; | ||||||
| import dynamicIconImports from "lucide-react/dynamicIconImports"; | ||||||
| import { useEffect, useRef, useState } from "react"; | ||||||
|
|
||||||
| import { cn } from "@/utils"; | ||||||
| import { | ||||||
| getLucideName, | ||||||
| lucideIconToDataUri, | ||||||
| toLucideIconRef, | ||||||
| } from "@/utils/lucideIconUrl"; | ||||||
|
|
||||||
| import { Button, Input, Popover, PopoverContent, PopoverTrigger } from "."; | ||||||
|
|
||||||
| const allIconNames = Object.keys(dynamicIconImports).sort(); | ||||||
|
kmcginnes marked this conversation as resolved.
Outdated
|
||||||
|
|
||||||
| const MAX_VISIBLE = 50; | ||||||
|
kmcginnes marked this conversation as resolved.
Outdated
|
||||||
|
|
||||||
| export function IconPicker({ | ||||||
| currentIconUrl, | ||||||
| onSelect, | ||||||
| }: { | ||||||
| /** | ||||||
| * The vertex's currently stored iconUrl. When this is a `lucide:<name>` | ||||||
| * reference, the matching grid cell is highlighted to indicate the | ||||||
| * current selection. | ||||||
| */ | ||||||
| currentIconUrl?: string; | ||||||
| /** | ||||||
| * Called with the symbolic `lucide:<name>` reference and the SVG MIME type. | ||||||
| * Resolution to a data URI happens at render time, not here. | ||||||
| */ | ||||||
| onSelect: (iconUrl: string, iconImageType: string) => void; | ||||||
| }) { | ||||||
| const [open, setOpen] = useState(false); | ||||||
| const [search, setSearch] = useState(""); | ||||||
| const inputRef = useRef<HTMLInputElement>(null); | ||||||
|
|
||||||
| const selectedName = getLucideName(currentIconUrl); | ||||||
| const filtered = filterIcons(search); | ||||||
|
|
||||||
| function handleSelect(iconName: string) { | ||||||
|
kmcginnes marked this conversation as resolved.
Outdated
|
||||||
| onSelect(toLucideIconRef(iconName), "image/svg+xml"); | ||||||
| setOpen(false); | ||||||
| setSearch(""); | ||||||
| } | ||||||
|
|
||||||
| useEffect(() => { | ||||||
| if (open) { | ||||||
| const timer = setTimeout(() => inputRef.current?.focus(), 100); | ||||||
| return () => clearTimeout(timer); | ||||||
| } | ||||||
| }, [open]); | ||||||
|
kmcginnes marked this conversation as resolved.
Outdated
|
||||||
|
|
||||||
| 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" | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
This will better match the color picker padding. |
||||||
| > | ||||||
| <Input | ||||||
| ref={inputRef} | ||||||
| placeholder="Search icons..." | ||||||
| value={search} | ||||||
| onChange={e => setSearch(e.target.value)} | ||||||
| className="h-8 text-sm" | ||||||
| /> | ||||||
|
kmcginnes marked this conversation as resolved.
|
||||||
| <div className="grid max-h-60 grid-cols-8 gap-1 overflow-y-auto"> | ||||||
|
kmcginnes marked this conversation as resolved.
Outdated
|
||||||
| {filtered.map(name => ( | ||||||
| <IconButton | ||||||
| key={name} | ||||||
| name={name} | ||||||
| selected={name === selectedName} | ||||||
| onSelect={handleSelect} | ||||||
| /> | ||||||
| ))} | ||||||
| {filtered.length === 0 && ( | ||||||
| <p className="text-text-secondary col-span-8 py-4 text-center text-sm"> | ||||||
| No icons found | ||||||
| </p> | ||||||
| )} | ||||||
|
kmcginnes marked this conversation as resolved.
|
||||||
| </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, | ||||||
| selected, | ||||||
| onSelect, | ||||||
| }: { | ||||||
| name: string; | ||||||
|
kmcginnes marked this conversation as resolved.
Outdated
|
||||||
| selected: boolean; | ||||||
| onSelect: (name: string) => void; | ||||||
|
kmcginnes marked this conversation as resolved.
Outdated
|
||||||
| }) { | ||||||
| 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} | ||||||
| aria-pressed={selected} | ||||||
| className={cn( | ||||||
| "hover:bg-background-contrast-secondary flex size-8 items-center justify-center rounded", | ||||||
| selected && "bg-primary-main/20 ring-primary-main ring-2", | ||||||
| )} | ||||||
| onClick={() => onSelect(name)} | ||||||
| > | ||||||
|
kmcginnes marked this conversation as resolved.
Outdated
|
||||||
| {src ? ( | ||||||
| <img src={src} alt={name} className="size-5" /> | ||||||
| ) : ( | ||||||
| <div className="bg-background-contrast-secondary size-5 animate-pulse rounded" /> | ||||||
| )} | ||||||
|
kmcginnes marked this conversation as resolved.
Outdated
|
||||||
| </button> | ||||||
| ); | ||||||
| } | ||||||
|
|
||||||
| function filterIcons(search: string): string[] { | ||||||
|
kmcginnes marked this conversation as resolved.
Outdated
|
||||||
| if (!search) return allIconNames.slice(0, MAX_VISIBLE); | ||||||
| const lower = search.toLowerCase(); | ||||||
| const results: string[] = []; | ||||||
|
kmcginnes marked this conversation as resolved.
Outdated
|
||||||
| for (const name of allIconNames) { | ||||||
| if (name.includes(lower)) { | ||||||
| results.push(name); | ||||||
| if (results.length >= MAX_VISIBLE) break; | ||||||
| } | ||||||
| } | ||||||
| return results; | ||||||
| } | ||||||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.