diff --git a/apps/docs/docs/advanced/icons/index.mdx b/apps/docs/docs/advanced/icons/index.mdx
index b73d49e02..01cd4b166 100644
--- a/apps/docs/docs/advanced/icons/index.mdx
+++ b/apps/docs/docs/advanced/icons/index.mdx
@@ -9,17 +9,19 @@ tags:
---
## Icon picker
+
Icons in Homarr are automatically requested from multiple sources:
- - [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with over 1888+ curated, high-quality icons
- - [selfh.st icons](https://github.com/selfhst/icons)
- - [Simple icons](https://github.com/simple-icons/simple-icons)
- - [Tabler icons](https://tabler-icons.io/)
- - [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme)
- - [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets)
- - **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
+
+- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with over 1888+ curated, high-quality icons
+- [selfh.st icons](https://github.com/selfhst/icons)
+- [Simple icons](https://github.com/simple-icons/simple-icons)
+- [Tabler icons](https://tabler-icons.io/)
+- [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme)
+- [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets)
+- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
Using these icon sources, Homarr provides a total collection of over 11,000 icons at your fingertips.
-The icon picker is a fast searcher that helps you to find your desired icon simply by entering a search term.
+The image picker searches this collection after a short typing delay. When a name is available, such as an app or search engine name, the picker uses it as the initial search. Results show your locally uploaded images first, followed by SVG icons and then other image formats.
### Finding Missing Icons
@@ -29,6 +31,13 @@ If you can't find the icon you're looking for in Homarr's built-in picker, we st
Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scalable_Vector_Graphics)) are marked with a red badge "SVG" at the top right.
+You can clear the selected image with the clear button or the usual keyboard shortcut for deleting a line (Command+Backspace on macOS or Control+Backspace on other platforms). Clearing the field does not restore the previous image.
+
### Using external icons
+
If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon.
-Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. It is important that the URL directly points to the file without any advertisements or elements around it.
+Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. The picker previews the image and shows whether it loaded successfully. It is important that the URL directly points to the file without any advertisements or elements around it.
+
+### Uploading a local image
+
+Users with media upload permission can select **Upload image** next to the field. After the upload finishes, the new local image is selected immediately and is available at the top of future searches.
diff --git a/apps/docs/docs/management/apps/index.mdx b/apps/docs/docs/management/apps/index.mdx
index 7c84df588..efd42277d 100644
--- a/apps/docs/docs/management/apps/index.mdx
+++ b/apps/docs/docs/management/apps/index.mdx
@@ -28,7 +28,7 @@ The name must be at least one character long and can be chosen freely.
### Icon URL
This is the URL to the icon that will be displayed on the app widget.
-You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories.
+You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. The app name is pre-filled as the initial library search when no image is selected. Local images appear first, followed by SVG icons and other formats. Direct image URLs are previewed, used as entered, and are not searched in the icon repositories.

diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx
index 2fcb119a0..e8aae6ba5 100644
--- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx
@@ -48,6 +48,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
{...form.getInputProps("logoImageUrl")}
label={t("board.field.logoImageUrl.label")}
placeholder="/logo/logo.png"
+ suggestedSearch={board.name}
withAsterisk={false}
/>
@@ -56,6 +57,7 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
{...form.getInputProps("faviconImageUrl")}
label={t("board.field.faviconImageUrl.label")}
placeholder="/logo/logo.png"
+ suggestedSearch={board.name}
withAsterisk={false}
/>
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx
index c2820757f..b5c429a92 100644
--- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx
@@ -697,7 +697,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
-
+
diff --git a/apps/nextjs/src/app/[locale]/manage/search-engines/_form.tsx b/apps/nextjs/src/app/[locale]/manage/search-engines/_form.tsx
index 8da0a45b8..e0abebe41 100644
--- a/apps/nextjs/src/app/[locale]/manage/search-engines/_form.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/search-engines/_form.tsx
@@ -57,7 +57,7 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
/>
-
+
[sql`CASE WHEN ${table.slug} = 'local' THEN 0 ELSE 1 END`, asc(table.slug)],
with: {
icons: {
columns: { id: true, name: true, url: true },
diff --git a/packages/forms-collection/src/icon-picker/icon-picker.module.css b/packages/forms-collection/src/icon-picker/icon-picker.module.css
index aa52af4b0..43b8d5ca2 100644
--- a/packages/forms-collection/src/icon-picker/icon-picker.module.css
+++ b/packages/forms-collection/src/icon-picker/icon-picker.module.css
@@ -1,3 +1,89 @@
-[data-combobox-selected="true"] .iconCard {
- border-color: var(--mantine-primary-color-6);
+.dropdown {
+ min-width: min(32rem, calc(100vw - 2rem));
+}
+
+.options {
+ max-height: min(28rem, 60vh);
+ overflow-y: auto;
+ overscroll-behavior: contain;
+}
+
+.iconGrid {
+ grid-template-columns: repeat(auto-fill, minmax(3.25rem, 1fr));
+}
+
+.iconOption {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 0;
+ height: 3.25rem;
+ padding: var(--mantine-spacing-xs);
+ border: 1px solid var(--mantine-color-default-border);
+ border-radius: var(--mantine-radius-md);
+ background: var(--mantine-color-body);
+ transition:
+ border-color 160ms ease,
+ background-color 160ms ease,
+ transform 160ms ease;
+}
+
+.iconOption:hover,
+.iconOption[data-combobox-selected="true"] {
+ border-color: var(--mantine-primary-color-6);
+ background: var(--mantine-primary-color-light);
+}
+
+.iconOption:active {
+ transform: scale(0.96);
+}
+
+.formatBadge,
+.sectionBadge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--mantine-radius-xs);
+ background: var(--mantine-color-red-9);
+ color: var(--mantine-color-white);
+ font-size: 0.625rem;
+ font-weight: 700;
+ line-height: 1;
+}
+
+.formatBadge {
+ position: absolute;
+ top: 0.2rem;
+ right: 0.2rem;
+ padding: 0.15rem 0.2rem;
+}
+
+.sectionBadge {
+ padding: 0.2rem 0.3rem;
+}
+
+.urlPreview {
+ background: var(--mantine-color-default-hover);
+}
+
+.previewFrame {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 3rem;
+ height: 3rem;
+ flex: 0 0 3rem;
+}
+
+.previewLoader {
+ position: absolute;
+ z-index: 1;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .iconOption {
+ transition: none;
+ }
}
diff --git a/packages/forms-collection/src/icon-picker/icon-picker.spec.ts b/packages/forms-collection/src/icon-picker/icon-picker.spec.ts
index 1046b341a..87ef7a6a8 100644
--- a/packages/forms-collection/src/icon-picker/icon-picker.spec.ts
+++ b/packages/forms-collection/src/icon-picker/icon-picker.spec.ts
@@ -4,31 +4,84 @@ import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vi
import { MantineProvider } from "@mantine/core";
import type { Root } from "react-dom/client";
+import type { IconGroup } from "./icon-picker.utils";
-const { findIconsQuery } = vi.hoisted(() => ({
- findIconsQuery: vi.fn((_query: { searchText: string }) => ({
- data: { icons: [], countIcons: 0 },
- isLoading: false,
- isError: false,
- refetch: vi.fn(),
- })),
+const { findIconsQuery, refetch, uploadSelection, sessionPermissions } = vi.hoisted(() => ({
+ findIconsQuery: vi.fn(),
+ refetch: vi.fn(),
+ uploadSelection: vi.fn(),
+ sessionPermissions: { current: [] as string[] },
}));
+interface QueryData {
+ icons: IconGroup[];
+ countIcons: number;
+}
+
+const emptyResult: QueryData = { icons: [], countIcons: 11_000 };
+let queryResult: {
+ data?: QueryData;
+ isLoading: boolean;
+ isFetching: boolean;
+ isError: boolean;
+};
+
vi.mock("@homarr/api/client", () => ({
- clientApi: { icon: { findIcons: { useQuery: findIconsQuery } } },
+ clientApi: {
+ icon: {
+ findIcons: {
+ useQuery: (input: { searchText: string; limitPerGroup: number }, options: { enabled: boolean }) => {
+ findIconsQuery(input, options);
+ return { ...queryResult, refetch };
+ },
+ },
+ },
+ },
}));
vi.mock("@homarr/auth/client", () => ({
- useSession: () => ({ data: { user: { permissions: [] } } }),
+ useSession: () => ({ data: { user: { permissions: sessionPermissions.current } } }),
}));
vi.mock("@homarr/translation", () => ({ supportedLanguages: [] }));
vi.mock("@homarr/translation/client", () => ({
- useScopedI18n: () => (key: string) => key,
+ useScopedI18n: () => (key: string, params?: Record) =>
+ params ? `${key}:${Object.values(params).join(",")}` : key,
+}));
+vi.mock("../upload-media/upload-media", () => ({
+ UploadMedia: ({
+ children,
+ onSuccess,
+ }: {
+ children: (props: object) => unknown;
+ onSuccess: (medias: unknown) => void;
+ }) =>
+ children({
+ loading: false,
+ onClick: () => {
+ uploadSelection();
+ onSuccess([{ id: "uploaded", url: "/api/user-medias/uploaded" }]);
+ },
+ }),
}));
-vi.mock("../upload-media/upload-media", () => ({ UploadMedia: () => null }));
import { IconPicker } from "./icon-picker";
-const directIconUrl = "https://cdn.example.com/shopify.svg";
+const directImageUrl = "https://cdn.example.com/shopify.svg";
+
+const iconGroups = [
+ {
+ id: "remote",
+ slug: "dashboard-icons",
+ icons: [
+ { id: "png", name: "shopify.png", url: "https://cdn.example.com/shopify.png" },
+ { id: "svg", name: "shopify.svg", url: "https://cdn.example.com/shopify.svg" },
+ ],
+ },
+ {
+ id: "local",
+ slug: "local",
+ icons: [{ id: "local-icon", name: "my-shop.png", url: "/api/user-medias/local-icon" }],
+ },
+];
const setInputValue = (input: HTMLInputElement, value: string) => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
@@ -37,6 +90,18 @@ const setInputValue = (input: HTMLInputElement, value: string) => {
input.dispatchEvent(new Event("input", { bubbles: true }));
};
+const pressKey = (input: HTMLInputElement, key: string, init?: KeyboardEventInit) => {
+ input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init }));
+};
+
+const focusInput = async (input: HTMLInputElement) => {
+ await act(async () => input.focus());
+};
+
+const advanceDebounce = async () => {
+ await act(async () => vi.advanceTimersByTimeAsync(300));
+};
+
describe("IconPicker", () => {
let container: HTMLDivElement;
let root: Root;
@@ -45,9 +110,9 @@ describe("IconPicker", () => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
Object.defineProperty(window, "matchMedia", {
writable: true,
- value: (query: string) => ({
+ value: (media: string) => ({
matches: false,
- media: query,
+ media,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
@@ -56,54 +121,186 @@ describe("IconPicker", () => {
dispatchEvent: () => false,
}),
});
+ Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { value: () => undefined, writable: true });
});
beforeEach(() => {
+ vi.useFakeTimers();
findIconsQuery.mockClear();
+ refetch.mockClear();
+ uploadSelection.mockClear();
+ sessionPermissions.current = [];
+ queryResult = { data: emptyResult, isLoading: false, isFetching: false, isError: false };
});
afterEach(() => {
- root.unmount();
+ act(() => root.unmount());
container.remove();
+ document.querySelectorAll("[data-combobox-dropdown]").forEach((element) => element.remove());
+ vi.useRealTimers();
});
- const renderPicker = async (onChange: (value: string) => void) => {
+ const renderPicker = async (
+ onChange: (value: string) => void,
+ props: Partial> = {},
+ ) => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
- root.render(createElement(MantineProvider, null, createElement(IconPicker, { value: "", onChange })));
+ root.render(createElement(MantineProvider, null, createElement(IconPicker, { value: "", onChange, ...props })));
});
const input = container.querySelector("input");
if (!input) throw new Error("Icon picker input was not rendered");
return input;
};
- test("keeps a direct URL visible without searching the icon repositories", async () => {
+ test("clears an existing value without restoring it", async () => {
const onChange = vi.fn();
- const input = await renderPicker(onChange);
+ const input = await renderPicker(onChange, { value: directImageUrl });
- await act(async () => {
- setInputValue(input, directIconUrl);
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
+ await act(async () => setInputValue(input, ""));
- expect(onChange).toHaveBeenCalledWith(directIconUrl);
- expect(input.value).toBe(directIconUrl);
- expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === directIconUrl)).toBe(false);
+ expect(onChange).toHaveBeenCalledWith("");
+ expect(input.value).toBe("");
});
- test("keeps icon names in repository search mode", async () => {
- const onChange = vi.fn();
- const input = await renderPicker(onChange);
+ test.each([{ metaKey: true }, { ctrlKey: true }])(
+ "clears the entire value with a line-delete shortcut",
+ async (modifier) => {
+ const onChange = vi.fn();
+ const input = await renderPicker(onChange, { value: directImageUrl });
- await act(async () => {
- setInputValue(input, "shopify");
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
+ await act(async () => pressKey(input, "Backspace", modifier));
- expect(onChange).not.toHaveBeenCalled();
- expect(input.value).toBe("shopify");
+ expect(onChange).toHaveBeenCalledWith("");
+ expect(input.value).toBe("");
+ },
+ );
+
+ test("debounces repository searches", async () => {
+ const input = await renderPicker(vi.fn());
+ findIconsQuery.mockClear();
+
+ await act(async () => setInputValue(input, "shopify"));
+ expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "shopify")).toBe(false);
+
+ await advanceDebounce();
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "shopify")).toBe(true);
});
+
+ test("pre-searches the suggested app name when opened", async () => {
+ const input = await renderPicker(vi.fn(), { suggestedSearch: "Jellyfin" });
+
+ await focusInput(input);
+ expect(input.value).toBe("Jellyfin");
+ await advanceDebounce();
+
+ expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "Jellyfin")).toBe(true);
+ });
+
+ test("shows local images before SVG and raster library images", async () => {
+ queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
+ const input = await renderPicker(vi.fn());
+
+ await focusInput(input);
+ const content = document.body.textContent ?? "";
+
+ expect(document.body.querySelector('[role="listbox"]')?.getAttribute("aria-label")).toBe("iconPicker.results");
+ expect(content.indexOf("iconPicker.localImages")).toBeLessThan(content.indexOf("iconPicker.svgIcons"));
+ expect(content.indexOf("iconPicker.svgIcons")).toBeLessThan(content.indexOf("iconPicker.otherImages"));
+ });
+
+ test("selects a library image with a pointer", async () => {
+ queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
+ const onChange = vi.fn();
+ const input = await renderPicker(onChange);
+ await focusInput(input);
+
+ const option = document.body.querySelector(
+ '[data-combobox-option][value="/api/user-medias/local-icon"]',
+ );
+ if (!option) throw new Error("Local image option was not rendered");
+ await act(async () => option.click());
+
+ expect(onChange).toHaveBeenCalledWith("/api/user-medias/local-icon");
+ expect(input.value).toBe("/api/user-medias/local-icon");
+ });
+
+ test("selects a library image with the keyboard", async () => {
+ queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
+ const onChange = vi.fn();
+ const input = await renderPicker(onChange);
+ await focusInput(input);
+
+ await act(async () => pressKey(input, "ArrowDown"));
+ await act(async () => pressKey(input, "Enter"));
+
+ expect(onChange).toHaveBeenCalledWith("/api/user-medias/local-icon");
+ });
+
+ test("keeps a direct URL visible and reports a successful preview", async () => {
+ const onChange = vi.fn();
+ const input = await renderPicker(onChange);
+
+ await act(async () => setInputValue(input, directImageUrl));
+
+ expect(onChange).toHaveBeenCalledWith(directImageUrl);
+ expect(input.value).toBe(directImageUrl);
+ expect(findIconsQuery.mock.calls.at(-1)?.[1]).toMatchObject({ enabled: false });
+ expect(document.body.textContent).toContain("iconPicker.urlLoading");
+
+ const preview = document.body.querySelector('[data-testid="direct-url-preview"]');
+ if (!preview) throw new Error("Direct URL preview was not rendered");
+ await act(async () => preview.dispatchEvent(new Event("load")));
+ expect(document.body.textContent).toContain("iconPicker.urlReady");
+ });
+
+ test("keeps a failed direct URL editable and shows an error", async () => {
+ const input = await renderPicker(vi.fn());
+ await act(async () => setInputValue(input, "https://invalid.example/image.png"));
+
+ const preview = document.body.querySelector('[data-testid="direct-url-preview"]');
+ if (!preview) throw new Error("Direct URL preview was not rendered");
+ await act(async () => preview.dispatchEvent(new Event("error")));
+
+ expect(input.value).toBe("https://invalid.example/image.png");
+ expect(document.body.textContent).toContain("iconPicker.urlError");
+ });
+
+ test("restores the committed value when an unfinished search is cancelled", async () => {
+ const input = await renderPicker(vi.fn(), { value: directImageUrl });
+
+ await act(async () => setInputValue(input, "unfinished search"));
+ await act(async () => pressKey(input, "Escape"));
+
+ expect(input.value).toBe(directImageUrl);
+ });
+
+ test("offers retry when the repository search fails", async () => {
+ queryResult = { data: undefined, isLoading: false, isFetching: false, isError: true };
+ const input = await renderPicker(vi.fn());
+ await focusInput(input);
+
+ const retry = Array.from(document.body.querySelectorAll("button")).find(
+ (button) => button.textContent === "action.tryAgain",
+ );
+ if (!retry) throw new Error("Retry button was not rendered");
+ await act(async () => retry.click());
+
+ expect(refetch).toHaveBeenCalledOnce();
+ });
+
+ test("selects an uploaded local image when the user has permission", async () => {
+ sessionPermissions.current = ["media-upload"];
+ const onChange = vi.fn();
+ await renderPicker(onChange);
+
+ const upload = container.querySelector('[aria-label="iconPicker.uploadImage"]');
+ if (!upload) throw new Error("Upload button was not rendered");
+ await act(async () => upload.click());
+
+ expect(uploadSelection).toHaveBeenCalledOnce();
+ expect(onChange).toHaveBeenCalledWith("/api/user-medias/uploaded");
+ });
});
diff --git a/packages/forms-collection/src/icon-picker/icon-picker.tsx b/packages/forms-collection/src/icon-picker/icon-picker.tsx
index e3e31c5be..940a1673f 100644
--- a/packages/forms-collection/src/icon-picker/icon-picker.tsx
+++ b/packages/forms-collection/src/icon-picker/icon-picker.tsx
@@ -1,27 +1,36 @@
"use client";
-import type { FocusEventHandler } from "react";
-import { startTransition, useEffect, useState } from "react";
+import type { FocusEventHandler, KeyboardEventHandler } from "react";
+import { startTransition, useEffect, useMemo, useState } from "react";
import {
ActionIcon,
Box,
Button,
- Card,
+ CloseButton,
Combobox,
- Flex,
Group,
Image,
- Indicator,
InputBase,
+ Loader,
Paper,
+ SimpleGrid,
Skeleton,
Stack,
Text,
- UnstyledButton,
+ ThemeIcon,
+ Tooltip,
useCombobox,
} from "@mantine/core";
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
-import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
+import {
+ IconCheck,
+ IconCloud,
+ IconMoodEmpty,
+ IconPhoto,
+ IconPhotoOff,
+ IconSearchOff,
+ IconUpload,
+} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
@@ -29,6 +38,7 @@ import { supportedLanguages } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import { UploadMedia } from "../upload-media/upload-media";
+import { arrangeIconPickerSections, isDirectImageUrl, isImageSource, isSvgImage } from "./icon-picker.utils";
import classes from "./icon-picker.module.css";
interface IconPickerProps {
@@ -39,9 +49,12 @@ interface IconPickerProps {
onBlur?: FocusEventHandler;
label?: string;
placeholder?: string;
+ suggestedSearch?: string | null;
withAsterisk?: boolean;
}
+type PreviewState = "idle" | "loading" | "ready" | "error";
+
export const IconPicker = ({
value: propsValue,
onChange,
@@ -51,188 +64,403 @@ export const IconPicker = ({
withAsterisk = true,
label,
placeholder,
+ suggestedSearch,
}: IconPickerProps) => {
- const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
- const [query, setQuery] = useState("");
-
- useEffect(() => {
- setQuery("");
- }, [value]);
-
+ const [value, setValue] = useUncontrolled({ value: propsValue, onChange, defaultValue: "" });
+ const [draft, setDraft] = useState(propsValue ?? "");
+ const [hasEdited, setHasEdited] = useState(false);
+ const [keyboardIndex, setKeyboardIndex] = useState(-1);
+ const [previewState, setPreviewState] = useState("idle");
const { data: session } = useSession();
const tCommon = useScopedI18n("common");
- const [debouncedQuery] = useDebouncedValue(query, 100);
+ const [debouncedDraft] = useDebouncedValue(draft, 250);
- const searchText = !isDirectIconUrl(debouncedQuery) && debouncedQuery !== (value ?? "") ? debouncedQuery : "";
+ const directUrl = isDirectImageUrl(draft) ? draft.trim() : null;
+ const searchText = directUrl || debouncedDraft === (value ?? "") ? "" : debouncedDraft.trim();
+ const canUpload = session?.user.permissions.includes("media-upload") ?? false;
- const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
- { searchText },
- { placeholderData: (prev) => prev },
+ const query = clientApi.icon.findIcons.useQuery(
+ { searchText, limitPerGroup: 24 },
+ { enabled: directUrl === null, placeholderData: (previous) => previous },
);
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
+ onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
});
- const totalOptions = data?.icons.reduce((acc, group) => acc + group.icons.length, 0) ?? 0;
- const groups = data?.icons.map((group) => {
- const options = group.icons.map((item) => (
-
- {
- startTransition(() => {
- setValue(item.url);
- setQuery("");
- combobox.closeDropdown();
- });
- }}
- >
-
-
-
-
-
-
-
-
-
- ));
+ useEffect(() => {
+ setDraft(value ?? "");
+ setHasEdited(false);
+ }, [value]);
- return (
-
-
- {group.slug}
-
-
- {options}
-
-
- );
- });
+ useEffect(() => {
+ setPreviewState(directUrl ? "loading" : "idle");
+ }, [directUrl]);
- const inputValue = query || value || "";
- const leftSection = shouldShowPreview(value) && ;
- const placeholderText =
- placeholder ||
- (data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
- tCommon("iconPicker.headerLoading");
+ const sections = useMemo(
+ () => arrangeIconPickerSections(query.data?.icons ?? [], searchText),
+ [query.data?.icons, searchText],
+ );
+ const orderedOptions = useMemo(
+ () => [...sections.local, ...sections.svg, ...sections.other],
+ [sections.local, sections.svg, sections.other],
+ );
+ const totalOptions = sections.local.length + sections.svg.length + sections.other.length;
- const renderDropdown = () => {
- if (isError) {
+ const commitValue = (nextValue: string) => {
+ startTransition(() => {
+ setValue(nextValue);
+ setDraft(nextValue);
+ setHasEdited(false);
+ setKeyboardIndex(-1);
+ });
+ };
+
+ const clearValue = () => {
+ commitValue("");
+ combobox.openDropdown("keyboard");
+ };
+
+ const openWithSuggestedSearch = () => {
+ if (!draft && !value && !hasEdited && suggestedSearch?.trim()) {
+ setDraft(suggestedSearch.trim());
+ }
+ combobox.openDropdown();
+ };
+
+ const handleKeyDown: KeyboardEventHandler = (event) => {
+ if ((event.metaKey || event.ctrlKey) && event.key === "Backspace") {
+ event.preventDefault();
+ clearValue();
+ return;
+ }
+
+ if (event.key === "Escape") {
+ setDraft(value ?? "");
+ setHasEdited(false);
+ combobox.closeDropdown("keyboard");
+ return;
+ }
+
+ if (!directUrl && orderedOptions.length > 0 && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
+ event.preventDefault();
+ combobox.openDropdown("keyboard");
+ setKeyboardIndex((current) => {
+ const next =
+ event.key === "ArrowDown"
+ ? current >= orderedOptions.length - 1
+ ? 0
+ : current + 1
+ : current <= 0
+ ? orderedOptions.length - 1
+ : current - 1;
+ combobox.selectOption(next);
+ return next;
+ });
+ return;
+ }
+
+ if (event.key === "Enter") {
+ if (keyboardIndex >= 0 && orderedOptions[keyboardIndex]) {
+ event.preventDefault();
+ commitValue(orderedOptions[keyboardIndex].url);
+ combobox.closeDropdown("keyboard");
+ } else if (isImageSource(draft)) {
+ event.preventDefault();
+ commitValue(draft.trim());
+ combobox.closeDropdown("keyboard");
+ }
+ }
+ };
+
+ const renderIconOptions = (icons: typeof sections.local, offset: number) => (
+
+ {icons.map((icon, index) => (
+
+
+
+ {isSvgImage(icon.url) && SVG }
+
+
+ ))}
+
+ );
+
+ const renderSearchResults = () => {
+ if (query.isError) {
return (
-
-
- {tCommon("iconPicker.error")}
- refetch()}>
- {tCommon("action.tryAgain")}
-
-
+ }
+ title={tCommon("iconPicker.error")}
+ action={
+ query.refetch()}>
+ {tCommon("action.tryAgain")}
+
+ }
+ />
);
}
- if (isLoading) {
- return Array(15)
- .fill(0)
- .map((_, index) => (
-
-
-
- ));
+
+ if (query.isLoading) {
+ return (
+
+ {Array.from({ length: 12 }, (_, index) => (
+
+ ))}
+
+ );
}
- if (totalOptions > 0) {
- return {groups} ;
+
+ if (totalOptions === 0) {
+ return (
+ }
+ title={tCommon("iconPicker.noResults", { search: debouncedDraft.trim() })}
+ />
+ );
}
+
return (
-
-
- {tCommon("iconPicker.noResults", { search: debouncedQuery })}
+
+ {sections.local.length > 0 && (
+ } label={tCommon("iconPicker.localImages")}>
+ {renderIconOptions(sections.local, 0)}
+
+ )}
+ {sections.svg.length > 0 && (
+
+ {renderIconOptions(sections.svg, sections.local.length)}
+
+ )}
+ {sections.other.length > 0 && (
+ } label={tCommon("iconPicker.otherImages")}>
+ {renderIconOptions(sections.other, sections.local.length + sections.svg.length)}
+
+ )}
);
};
+ const leftSection = (() => {
+ if (directUrl && previewState === "loading") return ;
+ if (directUrl && previewState === "error") return ;
+ if (shouldShowPreview(value)) {
+ return (
+ directUrl && setPreviewState("ready")}
+ onError={() => directUrl && setPreviewState("error")}
+ />
+ );
+ }
+ return ;
+ })();
+
+ const placeholderText =
+ placeholder ??
+ (query.data
+ ? tCommon("iconPicker.header", { countIcons: String(query.data.countIcons) })
+ : tCommon("iconPicker.headerLoading"));
+
return (
-
-
+ {
+ commitValue(url);
+ combobox.closeDropdown();
+ }}
+ >
+
}
leftSection={leftSection}
- value={inputValue}
+ rightSection={
+
+ {(draft || value) && (
+ event.preventDefault()}
+ onClick={clearValue}
+ />
+ )}
+ {query.isFetching && !directUrl ? : }
+
+ }
+ rightSectionWidth={56}
+ rightSectionPointerEvents="all"
+ value={draft}
onChange={(event) => {
const nextValue = event.currentTarget.value;
- const trimmedValue = nextValue.trim();
- setQuery(nextValue);
- if (isDirectIconUrl(trimmedValue)) {
- setValue(trimmedValue);
- combobox.closeDropdown();
- } else {
- combobox.openDropdown();
+ setDraft(nextValue);
+ setHasEdited(true);
+ setKeyboardIndex(-1);
+ combobox.openDropdown("keyboard");
+ combobox.updateSelectedOptionIndex();
+
+ if (!nextValue) {
+ setValue("");
+ } else if (isImageSource(nextValue)) {
+ setValue(nextValue.trim());
}
}}
- onClick={() => combobox.openDropdown()}
+ onKeyDown={handleKeyDown}
+ onClick={openWithSuggestedSearch}
onFocus={(event) => {
onFocus?.(event);
- combobox.openDropdown();
+ openWithSuggestedSearch();
}}
onBlur={(event) => {
onBlur?.(event);
combobox.closeDropdown();
- setQuery("");
+ setDraft(value ?? "");
+ setHasEdited(false);
}}
- rightSectionPointerEvents="none"
withAsterisk={withAsterisk}
error={error}
label={label ?? tCommon("iconPicker.label")}
+ description={tCommon("iconPicker.description")}
+ styles={{
+ description: { color: "var(--mantine-color-text)" },
+ input: { color: "var(--mantine-color-text)" },
+ error: { color: "light-dark(var(--mantine-color-red-9), var(--mantine-color-red-2))" },
+ }}
placeholder={placeholderText}
+ autoComplete="off"
+ spellCheck={false}
/>
- {session?.user.permissions.includes("media-upload") && (
+
+ {canUpload && (
{
const first = medias.at(0);
if (!first) return;
- startTransition(() => {
- setValue(first.url);
- });
+ commitValue(first.url);
+ combobox.closeDropdown();
}}
>
{({ onClick, loading }) => (
-
-
-
+
+
+
+
+
)}
)}
-
-
- {renderDropdown()}
+
+
+ {directUrl ? (
+
+
+
+ {previewState === "loading" && }
+ {previewState === "error" ? (
+
+
+
+ ) : (
+ setPreviewState("ready")}
+ onError={() => setPreviewState("error")}
+ />
+ )}
+
+
+
+ {previewState === "error"
+ ? tCommon("iconPicker.urlError")
+ : previewState === "ready"
+ ? tCommon("iconPicker.urlReady")
+ : tCommon("iconPicker.urlLoading")}
+
+
+ {directUrl}
+
+
+ {previewState === "ready" && (
+
+
+
+ )}
+
+
+ ) : (
+ renderSearchResults()
+ )}
);
};
-// This regex is used to prevent loading a preview like en or /en which would trigger a language change
-// See https://github.com/homarr-labs/homarr/issues/3070
+interface PickerSectionProps {
+ label: string;
+ icon?: React.ReactNode;
+ badge?: string;
+ children: React.ReactNode;
+}
+
+const PickerSection = ({ label, icon, badge, children }: PickerSectionProps) => (
+
+
+ {icon}
+
+ {label}
+
+ {badge && {badge} }
+
+ {children}
+
+);
+
+interface PickerMessageProps {
+ icon: React.ReactNode;
+ title: string;
+ action?: React.ReactNode;
+}
+
+const PickerMessage = ({ icon, title, action }: PickerMessageProps) => (
+
+
+ {icon}
+
+ {title}
+ {action}
+
+);
+
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
const shouldShowPreview = (value: string | null | undefined): value is string => {
if (!value) return false;
return !localizationPathRegex.test(value);
};
-
-const isDirectIconUrl = (value: string) => {
- try {
- const url = new URL(value.trim());
- return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname);
- } catch {
- return false;
- }
-};
diff --git a/packages/forms-collection/src/icon-picker/icon-picker.utils.spec.ts b/packages/forms-collection/src/icon-picker/icon-picker.utils.spec.ts
new file mode 100644
index 000000000..33a251683
--- /dev/null
+++ b/packages/forms-collection/src/icon-picker/icon-picker.utils.spec.ts
@@ -0,0 +1,76 @@
+import { describe, expect, test } from "vitest";
+
+import type { IconGroup } from "./icon-picker.utils";
+import {
+ arrangeIconPickerSections,
+ findBestIconMatch,
+ isDirectImageUrl,
+ isImageSource,
+ isSvgImage,
+} from "./icon-picker.utils";
+
+const groups = [
+ {
+ id: "remote",
+ slug: "dashboard-icons",
+ icons: [
+ { id: "long", name: "home-assistant-alt.svg", url: "https://cdn.example.com/home-assistant-alt.svg" },
+ { id: "raster", name: "Home Assistant.png", url: "https://cdn.example.com/home-assistant.png" },
+ { id: "svg", name: "home-assistant.svg", url: "https://cdn.example.com/home-assistant.svg?v=1" },
+ ],
+ },
+ {
+ id: "local",
+ slug: "local",
+ icons: [{ id: "local", name: "Home Assistant.png", url: "/api/user-medias/home-assistant" }],
+ },
+] as IconGroup[];
+
+describe("icon picker utilities", () => {
+ test("arranges local images before remote SVG and raster images", () => {
+ const result = arrangeIconPickerSections(groups, "home assistant");
+
+ expect(result.local.map((icon) => icon.id)).toEqual(["local"]);
+ expect(result.svg.map((icon) => icon.id)).toEqual(["svg", "long"]);
+ expect(result.other.map((icon) => icon.id)).toEqual(["raster"]);
+ });
+
+ test("normalizes punctuation and prefers a user's exact local image", () => {
+ expect(findBestIconMatch("home_assistant", groups)).toBe("/api/user-medias/home-assistant");
+ });
+
+ test("prefers SVG over raster when equally matched outside local images", () => {
+ expect(findBestIconMatch("Home Assistant", [groups[0] as IconGroup])).toBe(
+ "https://cdn.example.com/home-assistant.svg?v=1",
+ );
+ });
+
+ test("prefers an exact normalized name over a longer partial match", () => {
+ expect(findBestIconMatch("home assistant", [groups[0] as IconGroup])).toBe(
+ "https://cdn.example.com/home-assistant.svg?v=1",
+ );
+ });
+
+ test("does not return unrelated icons", () => {
+ expect(findBestIconMatch("jellyfin", groups)).toBeNull();
+ });
+
+ test.each([
+ ["https://example.com/icon.png", true],
+ ["http://localhost:3000/icon", true],
+ ["ftp://example.com/icon.png", false],
+ ["javascript:alert(1)", false],
+ ["example.com/icon.png", false],
+ ])("validates direct image URLs", (value, expected) => {
+ expect(isDirectImageUrl(value)).toBe(expected);
+ });
+
+ test("accepts internal image paths without treating search text as a source", () => {
+ expect(isImageSource("/api/user-medias/123")).toBe(true);
+ expect(isImageSource("shopify")).toBe(false);
+ });
+
+ test("recognizes SVG URLs with query strings case-insensitively", () => {
+ expect(isSvgImage("https://example.com/ICON.SVG?raw=1")).toBe(true);
+ });
+});
diff --git a/packages/forms-collection/src/icon-picker/icon-picker.utils.ts b/packages/forms-collection/src/icon-picker/icon-picker.utils.ts
new file mode 100644
index 000000000..83690b43b
--- /dev/null
+++ b/packages/forms-collection/src/icon-picker/icon-picker.utils.ts
@@ -0,0 +1,128 @@
+import type { inferRouterOutputs } from "@trpc/server";
+
+import type { AppRouter } from "@homarr/api";
+
+type RouterOutput = inferRouterOutputs;
+export type IconGroup = RouterOutput["icon"]["findIcons"]["icons"][number];
+export type PickerIcon = IconGroup["icons"][number] & { repositorySlug: string };
+
+export interface IconPickerSections {
+ local: PickerIcon[];
+ svg: PickerIcon[];
+ other: PickerIcon[];
+}
+
+export const arrangeIconPickerSections = (iconGroups: IconGroup[], searchTerm = ""): IconPickerSections => {
+ const sections: IconPickerSections = { local: [], svg: [], other: [] };
+
+ for (const group of iconGroups) {
+ for (const icon of group.icons) {
+ const pickerIcon = { ...icon, repositorySlug: group.slug };
+ if (group.slug === "local") {
+ sections.local.push(pickerIcon);
+ } else if (isSvgImage(icon.url)) {
+ sections.svg.push(pickerIcon);
+ } else {
+ sections.other.push(pickerIcon);
+ }
+ }
+ }
+
+ const normalizedSearch = normalizeImageName(searchTerm);
+ if (normalizedSearch) {
+ const compareMatches = (a: PickerIcon, b: PickerIcon) => {
+ const aRank = getMatchRank(normalizedSearch, a.name || a.url) ?? Number.MAX_SAFE_INTEGER;
+ const bRank = getMatchRank(normalizedSearch, b.name || b.url) ?? Number.MAX_SAFE_INTEGER;
+ return aRank - bRank || a.name.localeCompare(b.name);
+ };
+ return {
+ local: sections.local.toSorted(compareMatches),
+ svg: sections.svg.toSorted(compareMatches),
+ other: sections.other.toSorted(compareMatches),
+ };
+ }
+
+ return sections;
+};
+
+export const findBestIconMatch = (searchTerm: string, iconGroups: IconGroup[]): string | null => {
+ const normalizedSearch = normalizeImageName(searchTerm);
+ if (!normalizedSearch) return null;
+
+ const rankedIcons = iconGroups
+ .flatMap((group) =>
+ group.icons.map((icon) => ({
+ icon,
+ group,
+ rank: getMatchRank(normalizedSearch, icon.name || icon.url),
+ })),
+ )
+ .filter((entry) => entry.rank !== null)
+ .toSorted((a, b) => {
+ const quality = (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER);
+ if (quality !== 0) return quality;
+
+ const local = Number(b.group.slug === "local") - Number(a.group.slug === "local");
+ if (local !== 0) return local;
+
+ const svg = Number(isSvgImage(b.icon.url)) - Number(isSvgImage(a.icon.url));
+ if (svg !== 0) return svg;
+
+ return (
+ normalizeImageName(a.icon.name || a.icon.url).length - normalizeImageName(b.icon.name || b.icon.url).length
+ );
+ });
+
+ return rankedIcons.at(0)?.icon.url ?? null;
+};
+
+export const isDirectImageUrl = (value: string) => {
+ try {
+ const url = new URL(value.trim());
+ return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname);
+ } catch {
+ return false;
+ }
+};
+
+export const isImageSource = (value: string) => isDirectImageUrl(value) || value.trim().startsWith("/");
+
+export const isSvgImage = (value: string) => {
+ try {
+ const url = new URL(value, "http://homarr.local");
+ return url.pathname.toLowerCase().endsWith(".svg");
+ } catch {
+ return value.toLowerCase().split(/[?#]/, 1)[0]?.endsWith(".svg") ?? false;
+ }
+};
+
+const normalizeImageName = (value: string) => {
+ const fileName = value.split(/[?#]/, 1)[0]?.split("/").pop() ?? value;
+ const withoutExtension = fileName.replace(/\.(?:avif|gif|jpe?g|png|svg|webp)$/i, "");
+
+ return withoutExtension
+ .normalize("NFKD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .replace(/&/g, " and ")
+ .replace(/[^a-zA-Z0-9]+/g, " ")
+ .trim()
+ .toLowerCase();
+};
+
+const getMatchRank = (search: string, candidateValue: string): number | null => {
+ const candidate = normalizeImageName(candidateValue);
+ if (!candidate) return null;
+
+ const compactSearch = search.replaceAll(" ", "");
+ const compactCandidate = candidate.replaceAll(" ", "");
+ if (candidate === search) return 0;
+ if (compactCandidate === compactSearch) return 1;
+ if (candidate.startsWith(`${search} `)) return 2;
+
+ const searchTokens = search.split(" ");
+ const candidateTokens = candidate.split(" ");
+ if (searchTokens.every((term) => candidateTokens.some((token) => token === term))) return 3;
+ if (searchTokens.every((term) => candidateTokens.some((token) => token.startsWith(term)))) return 4;
+ if (candidate.includes(search)) return 5;
+ return null;
+};
diff --git a/packages/forms-collection/src/new-app/_form.tsx b/packages/forms-collection/src/new-app/_form.tsx
index 92f3a4c2f..ce119da91 100644
--- a/packages/forms-collection/src/new-app/_form.tsx
+++ b/packages/forms-collection/src/new-app/_form.tsx
@@ -3,17 +3,15 @@
import type { ChangeEventHandler } from "react";
import { useEffect, useImperativeHandle, useRef } from "react";
import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core";
-import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
+import { useDisclosure } from "@mantine/hooks";
import type { z } from "zod/v4";
-import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { appManageSchema } from "@homarr/validation/app";
import { IconPicker } from "../icon-picker/icon-picker";
-import { findBestIconMatch } from "./icon-matcher";
type FormType = z.infer;
@@ -81,9 +79,6 @@ export const AppForm = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValuesKey]);
- // Debounce the name value with 200ms delay
- const [debouncedName] = useDebouncedValue(form.values.name, 200);
-
const shouldCreateAnother = useRef(false);
const handleSubmit = (values: FormType) => {
const redirect = !shouldCreateAnother.current;
@@ -130,29 +125,10 @@ export const AppForm = ({
}
};
- // Auto-select icon based on app name with debounced search
- const { data: iconsData } = clientApi.icon.findIcons.useQuery(
- {
- searchText: debouncedName,
- },
- {
- enabled: debouncedName.length > 3,
- },
- );
-
- useEffect(() => {
- if (debouncedName && !form.values.iconUrl && iconsData?.icons) {
- const bestMatch = findBestIconMatch(debouncedName, iconsData.icons);
- if (bestMatch) {
- form.setFieldValue("iconUrl", bestMatch);
- }
- }
- }, [debouncedName, iconsData]);
-
const formFields = (
-
+