diff --git a/packages/graph-explorer/src/components/IconPicker.test.tsx b/packages/graph-explorer/src/components/IconPicker.test.tsx index 1018ff82e..171fdd9c7 100644 --- a/packages/graph-explorer/src/components/IconPicker.test.tsx +++ b/packages/graph-explorer/src/components/IconPicker.test.tsx @@ -1,18 +1,49 @@ // @vitest-environment happy-dom +import type { ComponentProps } from "react"; + import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { IconPicker } from "./IconPicker"; +import { TooltipProvider } from "./Tooltip"; + +// The icon tooltips require a TooltipProvider ancestor, which the app supplies +// globally in DefaultLayout. +function renderPicker(props: Partial> = {}) { + return render(, { + wrapper: TooltipProvider, + }); +} + +// Icon buttons are the only buttons in the picker with `aria-pressed`, and +// their tooltip surfaces the icon name as the accessible name (aria-label). +function visibleIconButtons() { + return screen + .getAllByRole("button") + .filter(btn => btn.hasAttribute("aria-pressed")); +} + +function firstVisibleIcon() { + return visibleIconButtons()[0]; +} + +function iconName(btn: HTMLElement) { + return btn.getAttribute("aria-label") ?? ""; +} + +function visibleIconNames() { + return visibleIconButtons().map(iconName); +} describe("IconPicker", () => { it("should render Browse button", () => { - render(); + renderPicker(); expect(screen.getByRole("button", { name: /browse/i })).toBeInTheDocument(); }); it("should open popover with search input on click", async () => { const user = userEvent.setup(); - render(); + renderPicker(); await user.click(screen.getByRole("button", { name: /browse/i })); @@ -21,22 +52,19 @@ describe("IconPicker", () => { it("should show icons in the grid", async () => { const user = userEvent.setup(); - render(); + renderPicker(); 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); + expect(visibleIconButtons().length).toBeGreaterThan(0); }); }); it("should filter icons when searching", async () => { const user = userEvent.setup(); - render(); + renderPicker(); await user.click(screen.getByRole("button", { name: /browse/i })); const searchInput = screen.getByPlaceholderText("Search icons..."); @@ -44,37 +72,177 @@ describe("IconPicker", () => { 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); + const matching = visibleIconButtons().filter(btn => + iconName(btn).includes("user"), + ); + expect(matching.length).toBeGreaterThan(0); }); }); - it("should show truncation hint when results are capped", async () => { + it("should show the pager when the icons span more than one page", async () => { const user = userEvent.setup(); - render(); + renderPicker(); await user.click(screen.getByRole("button", { name: /browse/i })); - expect(screen.getByText(/Showing 64 of/)).toBeInTheDocument(); + expect(screen.getByText(/Page 1 of/)).toBeInTheDocument(); }); - it("should hide truncation hint when fewer results than cap", async () => { + it("should hide the pager when the results fit on one page", async () => { const user = userEvent.setup(); - render(); + renderPicker(); await user.click(screen.getByRole("button", { name: /browse/i })); const searchInput = screen.getByPlaceholderText("Search icons..."); await user.type(searchInput, "airplay"); - expect(screen.queryByText(/Showing 64 of/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Page 1 of/)).not.toBeInTheDocument(); + }); + + it("should advance to the next page and show different icons", async () => { + const user = userEvent.setup(); + renderPicker(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + const firstPageIcons = visibleIconNames(); + + await user.click(screen.getByRole("button", { name: "Next page" })); + + expect(screen.getByText(/Page 2 of/)).toBeInTheDocument(); + expect(visibleIconNames()).not.toEqual(firstPageIcons); + }); + + it("should disable Previous on the first page", async () => { + const user = userEvent.setup(); + renderPicker(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + expect( + screen.getByRole("button", { name: "Previous page" }), + ).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Next page" }), + ).not.toBeDisabled(); + }); + + it("should disable Next on the last page", async () => { + const user = userEvent.setup(); + renderPicker(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + // "arrow" matches enough icons to fill exactly two pages. + await user.type(screen.getByPlaceholderText("Search icons..."), "arrow"); + await user.click(screen.getByRole("button", { name: "Next page" })); + + expect(screen.getByText(/Page 2 of 2/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Next page" })).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Previous page" }), + ).not.toBeDisabled(); + }); + + it("should return to the first page when the search changes", async () => { + const user = userEvent.setup(); + renderPicker(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + await user.click(screen.getByRole("button", { name: "Next page" })); + expect(screen.getByText(/Page 2 of/)).toBeInTheDocument(); + + await user.type(screen.getByPlaceholderText("Search icons..."), "a"); + + expect(screen.getByText(/Page 1 of/)).toBeInTheDocument(); + }); + + it("should clear the search when the popover is reopened", async () => { + const user = userEvent.setup(); + renderPicker(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + await user.type(screen.getByPlaceholderText("Search icons..."), "airplay"); + + // Close by pressing Escape, then reopen. + await user.keyboard("{Escape}"); + await waitFor(() => { + expect( + screen.queryByPlaceholderText("Search icons..."), + ).not.toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: /browse/i })); + + expect(screen.getByPlaceholderText("Search icons...")).toHaveValue(""); + }); + + it("should always open on the first page, even with a selection on a later page", async () => { + const user = userEvent.setup(); + // "zap" sorts near the end of the alphabetised list, well past page 1. + renderPicker({ currentIconUrl: "lucide:zap" }); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + expect(screen.getByText(/Page 1 of/)).toBeInTheDocument(); + }); + + it("should reopen on the same page after closing with Escape", async () => { + const user = userEvent.setup(); + renderPicker(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + await user.click(screen.getByRole("button", { name: "Next page" })); + expect(screen.getByText(/Page 2 of/)).toBeInTheDocument(); + + await user.keyboard("{Escape}"); + await waitFor(() => { + expect( + screen.queryByPlaceholderText("Search icons..."), + ).not.toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: /browse/i })); + + expect(screen.getByText(/Page 2 of/)).toBeInTheDocument(); + }); + + it("should reset to the first page when the component is remounted", async () => { + const user = userEvent.setup(); + // Remounting mirrors the node style dialog closing and reopening, which + // unmounts the picker and discards its page state. + const { unmount } = renderPicker(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + await user.click(screen.getByRole("button", { name: "Next page" })); + expect(screen.getByText(/Page 2 of/)).toBeInTheDocument(); + + unmount(); + renderPicker(); + await user.click(screen.getByRole("button", { name: /browse/i })); + + expect(screen.getByText(/Page 1 of/)).toBeInTheDocument(); + }); + + it("should reopen on the same page after selecting an icon", async () => { + const user = userEvent.setup(); + renderPicker(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + await user.click(screen.getByRole("button", { name: "Next page" })); + expect(screen.getByText(/Page 2 of/)).toBeInTheDocument(); + + await user.click(firstVisibleIcon()); + await waitFor(() => { + expect( + screen.queryByPlaceholderText("Search icons..."), + ).not.toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: /browse/i })); + + expect(screen.getByText(/Page 2 of/)).toBeInTheDocument(); }); it("should show no results message for invalid search", async () => { const user = userEvent.setup(); - render(); + renderPicker(); await user.click(screen.getByRole("button", { name: /browse/i })); const searchInput = screen.getByPlaceholderText("Search icons..."); @@ -87,59 +255,41 @@ describe("IconPicker", () => { it("should call onSelect with lucide: reference when icon is clicked", async () => { const user = userEvent.setup(); const onSelect = vi.fn(); - render(); + renderPicker({ 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); + expect(visibleIconButtons().length).toBeGreaterThan(0); }); - const firstIcon = screen - .getAllByRole("button") - .filter(btn => btn.title && btn.title !== "")[0]; - const iconName = firstIcon.getAttribute("title"); + const firstIcon = firstVisibleIcon(); + const name = iconName(firstIcon); await user.click(firstIcon); - expect(onSelect).toHaveBeenCalledWith( - `lucide:${iconName}`, - "image/svg+xml", - ); + expect(onSelect).toHaveBeenCalledWith(`lucide:${name}`, "image/svg+xml"); }); it("should highlight the icon matching currentIconUrl", async () => { const user = userEvent.setup(); - render(); + renderPicker({ currentIconUrl: "lucide:airplay" }); await user.click(screen.getByRole("button", { name: /browse/i })); await waitFor(() => { - const airplayBtn = screen - .getAllByRole("button") - .find(btn => btn.title === "airplay"); - expect(airplayBtn).toBeDefined(); + const airplayBtn = screen.getByRole("button", { name: "airplay" }); 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( - , - ); + renderPicker({ currentIconUrl: "data:image/svg+xml;base64,XXXX" }); await user.click(screen.getByRole("button", { name: /browse/i })); await waitFor(() => { - const iconButtons = screen - .getAllByRole("button") - .filter(btn => btn.title && btn.title !== ""); + const iconButtons = visibleIconButtons(); expect(iconButtons.length).toBeGreaterThan(0); for (const btn of iconButtons) { expect(btn).toHaveAttribute("aria-pressed", "false"); @@ -149,21 +299,15 @@ describe("IconPicker", () => { it("should close popover after selecting an icon", async () => { const user = userEvent.setup(); - render(); + renderPicker(); 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); + expect(visibleIconButtons().length).toBeGreaterThan(0); }); - const firstIcon = screen - .getAllByRole("button") - .filter(btn => btn.title && btn.title !== "")[0]; - await user.click(firstIcon); + await user.click(firstVisibleIcon()); await waitFor(() => { expect( diff --git a/packages/graph-explorer/src/components/IconPicker.tsx b/packages/graph-explorer/src/components/IconPicker.tsx index 724f82225..d8ec32cc6 100644 --- a/packages/graph-explorer/src/components/IconPicker.tsx +++ b/packages/graph-explorer/src/components/IconPicker.tsx @@ -1,4 +1,4 @@ -import { SearchIcon } from "lucide-react"; +import { ChevronLeftIcon, ChevronRightIcon, SearchIcon } from "lucide-react"; import { DynamicIcon } from "lucide-react/dynamic"; import { useState } from "react"; @@ -21,7 +21,7 @@ import { PopoverTrigger, } from "."; -const MAX_VISIBLE = 64; +const PAGE_SIZE = 64; export function IconPicker({ currentIconUrl, @@ -41,9 +41,25 @@ export function IconPicker({ }) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); + const [page, setPage] = useState(0); const selectedName = getLucideName(currentIconUrl); const filtered = filterIcons(search); + const pageCount = Math.ceil(filtered.length / PAGE_SIZE); + const pageStart = page * PAGE_SIZE; + const pageRows = filtered.slice(pageStart, pageStart + PAGE_SIZE); + + function handleOpenChange(isOpen: boolean) { + setOpen(isOpen); + if (!isOpen) { + setSearch(""); + } + } + + function handleSearchChange(value: string) { + setSearch(value); + setPage(0); + } function handleSelect(iconName: IconName) { onSelect(toLucideIconRef(iconName), "image/svg+xml"); @@ -52,7 +68,7 @@ export function IconPicker({ } return ( - + + + + + ); +} + function IconButton({ name, selected, @@ -111,7 +166,7 @@ function IconButton({ }) { return (