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:
@@ -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">▾</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>
|
||||
Reference in New Issue
Block a user