feat(widget-config): visual app selector grid with search and icons

Replace plain select dropdown with a searchable 2-column grid showing
app icons for the app widget type in the inline config panel.
This commit is contained in:
2026-04-03 00:32:45 +03:00
parent c5f5f84c79
commit 17c8407c07
2 changed files with 65 additions and 8 deletions
@@ -2,11 +2,19 @@
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import { tick } from 'svelte';
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
interface AppInfo {
id: string;
name: string;
icon?: string | null;
iconType?: string;
}
interface Props {
widgetType: string;
initialConfig?: Record<string, unknown>;
apps?: Array<{ id: string; name: string }>;
apps?: AppInfo[];
mode: 'create' | 'edit';
onSave: (config: Record<string, unknown>) => void;
onCancel: () => void;
@@ -14,6 +22,14 @@
let { widgetType, initialConfig = {}, apps = [], mode, onSave, onCancel }: Props = $props();
// App search
let appSearchQuery = $state('');
const filteredApps = $derived(
appSearchQuery.trim()
? apps.filter((a) => a.name.toLowerCase().includes(appSearchQuery.toLowerCase()))
: apps
);
// -- Form fields initialised from config --
// App
let appId = $state((initialConfig.appId as string) ?? '');
@@ -173,12 +189,53 @@
{#if widgetType === 'app'}
<div>
<label class={labelClass}>{$t('widget.app') ?? 'App'}</label>
<select bind:value={appId} class={inputClass} bind:this={firstInput}>
<option value="">Select an app...</option>
{#each apps as app}
<option value={app.id}>{app.name}</option>
<!-- Search -->
<div class="relative mb-2">
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
bind:value={appSearchQuery}
bind:this={firstInput}
placeholder={$t('common.search') ?? 'Search apps...'}
class="w-full rounded-lg border border-input bg-background py-1.5 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring/30"
/>
</div>
<!-- App grid -->
<div class="max-h-48 overflow-y-auto rounded-lg border border-input bg-background p-1">
{#if filteredApps.length === 0}
<p class="py-4 text-center text-xs text-muted-foreground">{$t('common.no_results') ?? 'No apps found'}</p>
{:else}
<div class="grid grid-cols-2 gap-1">
{#each filteredApps as app}
<button
type="button"
onclick={() => { appId = app.id; }}
class="flex items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition-colors
{appId === app.id
? 'bg-primary/10 text-primary ring-1 ring-primary/30'
: 'text-foreground hover:bg-accent'}"
>
{#if app.icon && app.iconType === 'lucide'}
<DynamicIcon name={app.icon} size={18} />
{:else if app.icon && app.iconType === 'url'}
<img src={app.icon} alt="" class="h-[18px] w-[18px] rounded object-contain" />
{:else if app.icon && app.iconType === 'simple'}
<img src="https://cdn.simpleicons.org/{app.icon.toLowerCase()}" alt="" class="h-[18px] w-[18px]" />
{:else if app.icon && app.iconType === 'emoji'}
<span class="text-base leading-none">{app.icon}</span>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground">
<rect x="2" y="3" width="20" height="14" rx="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" />
</svg>
{/if}
<span class="truncate">{app.name}</span>
</button>
{/each}
</select>
</div>
{/if}
</div>
</div>
{:else if widgetType === 'bookmark'}
+1 -1
View File
@@ -109,7 +109,7 @@
}
}
const appsForPicker = $derived(allApps.map((a) => ({ id: a.id, name: a.name })));
const appsForPicker = $derived(allApps.map((a) => ({ id: a.id, name: a.name, icon: a.icon, iconType: a.iconType })));
</script>
{#if widgets.length === 0 && !editMode.active}