feat: multi-entity picker for status widget app selection

- Create MultiEntityPicker component with search, checkboxes, keyboard nav
- Replace plain checkbox list in status widget config with MultiEntityPicker
- Render app icons properly by type (lucide, simple, url, emoji)
This commit is contained in:
2026-04-10 19:05:03 +03:00
parent f559c93e19
commit 5af670fa3c
2 changed files with 345 additions and 57 deletions
@@ -0,0 +1,253 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { Search } from 'lucide-svelte';
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
export interface MultiEntityPickerItem {
value: string;
label: string;
icon?: string | null;
iconType?: string | null;
desc?: string | null;
}
interface Props {
items: MultiEntityPickerItem[];
values: string[];
onchange?: (values: string[]) => void;
placeholder?: string;
searchPlaceholder?: string;
name?: string;
}
let {
items,
values = $bindable(),
onchange,
placeholder = '',
searchPlaceholder = '',
name
}: Props = $props();
let open = $state(false);
let query = $state('');
let highlightIdx = $state(0);
let listEl = $state<HTMLDivElement>();
let inputEl = $state<HTMLInputElement>();
const selectedCount = $derived(values.length);
const selectedLabels = $derived(
values
.map((v) => items.find((i) => i.value === v)?.label)
.filter(Boolean)
.join(', ')
);
const filtered = $derived.by(() => {
if (!query.trim()) return items;
const q = query.toLowerCase().trim();
return items.filter(
(i) =>
i.label.toLowerCase().includes(q) || (i.desc && i.desc.toLowerCase().includes(q))
);
});
$effect(() => {
if (open) {
highlightIdx = 0;
}
});
function openPicker() {
query = '';
highlightIdx = 0;
open = true;
requestAnimationFrame(() => inputEl?.focus());
}
function closePicker() {
open = false;
query = '';
}
function toggleItem(item: MultiEntityPickerItem) {
const idx = values.indexOf(item.value);
const next = idx >= 0
? values.filter((v) => v !== item.value)
: [...values, item.value];
values = next;
onchange?.(next);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightIdx = Math.min(highlightIdx + 1, filtered.length - 1);
scrollHighlightIntoView();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightIdx = Math.max(highlightIdx - 1, 0);
scrollHighlightIntoView();
} else if (e.key === 'Enter') {
e.preventDefault();
if (filtered[highlightIdx]) {
toggleItem(filtered[highlightIdx]);
}
} else if (e.key === 'Escape') {
e.preventDefault();
closePicker();
} else if (e.key === 'Tab') {
e.preventDefault();
}
}
function scrollHighlightIntoView() {
requestAnimationFrame(() => {
const el = listEl?.querySelector('.mep-highlight');
el?.scrollIntoView({ block: 'nearest' });
});
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) closePicker();
}
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
query;
highlightIdx = 0;
});
</script>
{#if name}
{#each values as v}
<input type="hidden" {name} value={v} />
{/each}
{/if}
<button
type="button"
class="flex w-full items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors hover:border-primary focus:outline-none focus:ring-2 focus:ring-ring"
onclick={openPicker}
>
{#if selectedCount > 0}
<span class="flex-1 truncate text-left">{selectedLabels}</span>
<span class="shrink-0 rounded-full bg-primary/15 px-1.5 py-0.5 text-[10px] font-medium text-primary">{selectedCount}</span>
{:else if placeholder}
<span class="flex-1 text-left text-muted-foreground">{placeholder}</span>
{/if}
<span class="shrink-0 text-xs text-muted-foreground">&#x25BE;</span>
</button>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[15vh] backdrop-blur-sm"
onclick={handleBackdropClick}
onkeydown={() => {}}
style="animation: mepFadeIn 0.15s ease-out"
>
<div
class="flex w-full max-w-md flex-col rounded-lg border border-border bg-popover shadow-2xl"
style="max-height: 60vh; animation: mepSlideDown 0.2s cubic-bezier(0.16, 1, 0.3, 1)"
role="dialog"
aria-label={searchPlaceholder || 'Select items'}
>
<!-- Search row -->
<div class="flex items-center gap-2 border-b border-border px-3 py-2.5">
<Search size={16} class="shrink-0 text-muted-foreground" />
<input
bind:this={inputEl}
bind:value={query}
type="text"
placeholder={searchPlaceholder || $t('common.search') || 'Search...'}
class="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
autocomplete="off"
spellcheck="false"
onkeydown={handleKeydown}
/>
{#if selectedCount > 0}
<span class="shrink-0 text-xs text-muted-foreground">{selectedCount} selected</span>
{/if}
</div>
<!-- List -->
<div class="overflow-y-auto py-1" bind:this={listEl}>
{#if filtered.length === 0}
<div class="py-6 text-center text-sm text-muted-foreground"></div>
{:else}
{#each filtered as item, i (item.value)}
{@const isSelected = values.includes(item.value)}
<button
type="button"
class="flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors
{i === highlightIdx ? 'mep-highlight bg-accent' : 'hover:bg-accent/50'}"
onclick={() => toggleItem(item)}
onmouseenter={() => (highlightIdx = i)}
>
<!-- Checkbox -->
<span
class="flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors
{isSelected
? 'border-primary bg-primary text-primary-foreground'
: 'border-input bg-background'}"
>
{#if isSelected}
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
{/if}
</span>
{#if item.icon}
<span class="flex h-5 w-5 shrink-0 items-center justify-center text-muted-foreground">
{#if item.iconType === 'lucide'}
<DynamicIcon name={item.icon} size={16} />
{:else if item.iconType === 'url'}
<img src={item.icon} alt="" class="h-4 w-4 rounded object-contain" />
{:else if item.iconType === 'simple'}
<img src="https://cdn.simpleicons.org/{item.icon.toLowerCase()}" alt="" class="h-4 w-4" />
{:else if item.iconType === 'emoji'}
<span class="text-sm leading-none">{item.icon}</span>
{:else}
<span class="text-sm leading-none">{item.icon}</span>
{/if}
</span>
{/if}
<span class="flex-1 truncate text-foreground">{item.label}</span>
{#if item.desc}
<span class="shrink-0 truncate text-xs text-muted-foreground" style="max-width: 40%">
{item.desc}
</span>
{/if}
</button>
{/each}
{/if}
</div>
<!-- Footer -->
<div class="flex items-center justify-between border-t border-border px-3 py-1.5">
<span class="text-[11px] text-muted-foreground">
↑↓ navigate · Enter toggle · Esc close
</span>
<button
type="button"
onclick={closePicker}
class="rounded px-2 py-0.5 text-xs font-medium text-primary transition-colors hover:bg-primary/10"
>
{$t('common.done') ?? 'Done'}
</button>
</div>
</div>
</div>
{/if}
<style>
@keyframes mepFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes mepSlideDown {
from { opacity: 0; transform: translateY(-12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
</style>