From e01571b221812cd19a6d67ebe3cf44432104ec27 Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 22 Jun 2026 15:22:25 +0200 Subject: [PATCH] fix(icon-picker): keep URL visible in edit mode, add SVG-first ordering (#6051) --- packages/api/src/router/icons.ts | 34 +++-- .../src/icon-picker/icon-picker.tsx | 140 ++++++++++-------- packages/translation/src/lang/en.json | 5 +- 3 files changed, 107 insertions(+), 72 deletions(-) diff --git a/packages/api/src/router/icons.ts b/packages/api/src/router/icons.ts index e75eda8ab..4f0e59d33 100644 --- a/packages/api/src/router/icons.ts +++ b/packages/api/src/router/icons.ts @@ -15,20 +15,36 @@ export const iconsRouter = createTRPCRouter({ }) .input(iconsFindSchema) .query(async ({ ctx, input }) => { + const term = input.searchText?.toLowerCase().trim() ?? ""; + const keywords = (input.searchText ?? "").split(" ").filter((keyword) => keyword.length > 0); + + let whereCondition = undefined; + if (term.length > 0) { + whereCondition = and(...keywords.map((keyword) => like(icons.name, `%${keyword}%`))); + } + return { icons: await ctx.db.query.iconRepositories.findMany({ with: { icons: { - columns: { - id: true, - name: true, - url: true, + columns: { id: true, name: true, url: true }, + where: whereCondition, + orderBy: (table, { asc, sql }) => { + // ponytail: SVG first, then alphabetical; with a search term, + // prepend a relevance tier (exact > prefix > substring). + const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`; + const nameAsc = asc(table.name); + if (term.length === 0) { + return [svgFirst, nameAsc]; + } + const tier = sql`CASE + WHEN LOWER(${table.name}) = ${term} THEN 0 + WHEN LOWER(${table.name}) LIKE ${term + "%"} THEN 1 + WHEN LOWER(${table.name}) LIKE ${`%${term}%`} THEN 2 + ELSE 3 + END`; + return [tier, svgFirst, nameAsc]; }, - where: - (input.searchText?.length ?? 0) > 0 - ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`))) - : undefined, limit: input.limitPerGroup, }, }, diff --git a/packages/forms-collection/src/icon-picker/icon-picker.tsx b/packages/forms-collection/src/icon-picker/icon-picker.tsx index f407196a6..43006ae60 100644 --- a/packages/forms-collection/src/icon-picker/icon-picker.tsx +++ b/packages/forms-collection/src/icon-picker/icon-picker.tsx @@ -1,8 +1,11 @@ +"use client"; + import type { FocusEventHandler } from "react"; -import { startTransition } from "react"; +import { startTransition, useEffect, useState } from "react"; import { ActionIcon, Box, + Button, Card, Combobox, Flex, @@ -18,7 +21,7 @@ import { useCombobox, } from "@mantine/core"; import { useDebouncedValue, useUncontrolled } from "@mantine/hooks"; -import { IconUpload } from "@tabler/icons-react"; +import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; @@ -34,7 +37,6 @@ interface IconPickerProps { error?: string | null; onFocus?: FocusEventHandler; onBlur?: FocusEventHandler; - label?: string; placeholder?: string; withAsterisk?: boolean; @@ -50,29 +52,30 @@ export const IconPicker = ({ label, placeholder, }: IconPickerProps) => { - const [value, setValue] = useUncontrolled({ - value: propsValue, - onChange, - }); - const [search, setSearch] = useUncontrolled({ - value, - onChange: (value) => { - setValue(value); - }, - }); - const [previewUrl, setPreviewUrl] = useUncontrolled({ - value: propsValue ?? null, - }); + const [value, setValue] = useUncontrolled({ value: propsValue, onChange }); + const [query, setQuery] = useState(""); + + // ponytail: reset the typed query when the committed value changes + // externally (form reset, parent re-render) so the input reverts to the URL. + useEffect(() => { + setQuery(""); + }, [value]); + const { data: session } = useSession(); - const tCommon = useScopedI18n("common"); + const [debouncedQuery] = useDebouncedValue(query, 100); - const [debouncedSearch] = useDebouncedValue(search, 100); + // ponytail: only search when the typed query differs from the committed + // value. On mount the input shows the existing URL (edit mode), which + // would never match the bare filenames in the icon table. + const searchText = (debouncedQuery !== (value ?? "") && debouncedQuery) || ""; - // We use not useSuspenseQuery as it would cause an above Suspense boundary to trigger and so searching for something is bad UX. - const { data } = clientApi.icon.findIcons.useQuery({ - searchText: debouncedSearch, - }); + // ponytail: not useSuspenseQuery — an above Suspense boundary would + // trigger on every keystroke and the dropdown flash is bad UX. + const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery( + { searchText }, + { placeholderData: (prev) => prev }, + ); const combobox = useCombobox({ onDropdownClose: () => combobox.resetSelectedOption(), @@ -90,25 +93,15 @@ export const IconPicker = ({ > { - const value = item.url; startTransition(() => { - setPreviewUrl(value); - setSearch(value); - setValue(value); + setValue(item.url); + setQuery(""); combobox.closeDropdown(); }); }} > - + @@ -130,6 +123,48 @@ export const IconPicker = ({ ); }); + // ponytail: show the typed query if non-empty, else fall back to the URL. + const inputValue = query || value || ""; + // ponytail: show the existing icon as a left-section preview. + const leftSection = shouldShowPreview(value) && ; + // ponytail: prop override -> real count -> loading text. + const placeholderText = + placeholder || + (data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) || + tCommon("iconPicker.headerLoading"); + + const renderDropdown = () => { + if (isError) { + return ( + + + {tCommon("iconPicker.error")} + + + ); + } + if (isLoading) { + return Array(15) + .fill(0) + .map((_, index) => ( + + + + )); + } + if (totalOptions > 0) { + return {groups}; + } + return ( + + + {tCommon("iconPicker.noResults", { search: debouncedQuery })} + + ); + }; + return ( @@ -137,16 +172,11 @@ export const IconPicker = ({ } - leftSection={ - shouldShowPreview(previewUrl) ? : null - } - value={search} + leftSection={leftSection} + value={inputValue} onChange={(event) => { combobox.openDropdown(); - combobox.updateSelectedOptionIndex(); - setSearch(event.currentTarget.value); - setValue(event.currentTarget.value); - setPreviewUrl(null); + setQuery(event.currentTarget.value); }} onClick={() => combobox.openDropdown()} onFocus={(event) => { @@ -156,25 +186,21 @@ export const IconPicker = ({ onBlur={(event) => { onBlur?.(event); combobox.closeDropdown(); - setPreviewUrl(value); - setSearch(value || ""); + setQuery(""); }} rightSectionPointerEvents="none" withAsterisk={withAsterisk} error={error} label={label ?? tCommon("iconPicker.label")} - placeholder={placeholder ?? tCommon("iconPicker.header", { countIcons: String(data?.countIcons ?? 0) })} + placeholder={placeholderText} /> {session?.user.permissions.includes("media-upload") && ( { const first = medias.at(0); if (!first) return; - startTransition(() => { setValue(first.url); - setPreviewUrl(first.url); - setSearch(first.url); }); }} > @@ -190,17 +216,7 @@ export const IconPicker = ({ - {totalOptions > 0 ? ( - {groups} - ) : ( - Array(15) - .fill(0) - .map((_, index: number) => ( - - - - )) - )} + {renderDropdown()} @@ -209,8 +225,8 @@ export const IconPicker = ({ // 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 -const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i" /* ignore casing */); -const shouldShowPreview = (value: string | null): value is string => { +const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i"); +const shouldShowPreview = (value: string | null | undefined): value is string => { if (!value) return false; return !localizationPathRegex.test(value); }; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 8ec28198b..c831c6223 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1412,7 +1412,10 @@ "here": "here", "iconPicker": { "label": "Icon URL", - "header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you." + "header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.", + "headerLoading": "Type to search icons…", + "noResults": "No icons match \"{search}\".", + "error": "Couldn't load icons." }, "colorScheme": { "options": {