Files
web-app-launcher/src/lib/components/ui/IconGrid.svelte
T
alexei.dolgolyov 8d7847889e
CI / lint-and-check (push) Failing after 4m56s
CI / test (push) Has been skipped
CI / docker-build (push) Has been skipped
feat: add IconGrid, EntityPicker controls and enhance search panel
Port icon grid and entity picker patterns from wled-screen-controller.
IconGrid replaces plain <select> elements with visual icon grids for
known item sets (widget type, icon type, healthcheck method, permission
level). EntityPicker replaces search dropdowns with a command-palette
style overlay with keyboard navigation and filtering.

Enhance SearchDialog with keyboard navigation (arrow keys, Enter,
Escape), grouped results with section headers, active highlight,
and a footer with shortcut hints.
2026-03-25 11:58:21 +03:00

190 lines
5.0 KiB
Svelte

<script lang="ts">
import { t } from 'svelte-i18n';
export interface IconGridItem {
value: string;
icon: string;
label: string;
desc?: string;
}
interface Props {
items: IconGridItem[];
value: string;
onchange?: (value: string) => void;
columns?: number;
placeholder?: string;
name?: string;
}
let { items, value = $bindable(), onchange, columns = 2, placeholder = '', name }: Props =
$props();
let open = $state(false);
let triggerEl: HTMLButtonElement;
let popupEl: HTMLDivElement;
let filterQuery = $state('');
const selectedItem = $derived(items.find((i) => i.value === value));
const showFilter = $derived(items.length > 9);
const filteredItems = $derived(
filterQuery
? items.filter((i) => {
const text = `${i.label} ${i.desc || ''}`.toLowerCase();
return text.includes(filterQuery.toLowerCase());
})
: items
);
function toggle() {
if (open) {
close();
} else {
openPopup();
}
}
function openPopup() {
open = true;
filterQuery = '';
requestAnimationFrame(() => positionPopup());
}
function close() {
open = false;
filterQuery = '';
}
function select(item: IconGridItem) {
value = item.value;
close();
onchange?.(item.value);
}
function positionPopup() {
if (!triggerEl || !popupEl) return;
const rect = triggerEl.getBoundingClientRect();
const gap = 6;
const pad = 8;
const popupW = Math.max(rect.width, 220);
const spaceBelow = window.innerHeight - rect.bottom - gap - pad;
const spaceAbove = rect.top - gap - pad;
const openUp = spaceBelow < 200 && spaceAbove > spaceBelow;
const available = openUp ? spaceAbove : spaceBelow;
let left = rect.left;
if (left + popupW > window.innerWidth - pad) left = window.innerWidth - pad - popupW;
if (left < pad) left = pad;
popupEl.style.left = `${left}px`;
popupEl.style.width = `${popupW}px`;
popupEl.style.maxHeight = `${available}px`;
if (openUp) {
popupEl.style.top = '';
popupEl.style.bottom = `${window.innerHeight - rect.top + gap}px`;
} else {
popupEl.style.top = `${rect.bottom + gap}px`;
popupEl.style.bottom = '';
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
close();
}
}
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (open && !target.closest('.icon-grid-popup') && !target.closest('.icon-grid-trigger')) {
close();
}
}
</script>
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
{#if name}
<input type="hidden" {name} {value} />
{/if}
<button
type="button"
class="icon-grid-trigger 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"
bind:this={triggerEl}
onclick={toggle}
>
{#if selectedItem}
<span class="flex h-5 w-5 shrink-0 items-center justify-center text-muted-foreground">
<span class="text-base">{selectedItem.icon}</span>
</span>
<span class="flex-1 text-left">{selectedItem.label}</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}
<div
class="icon-grid-popup fixed z-50 overflow-y-auto rounded-lg border border-border bg-popover shadow-xl"
bind:this={popupEl}
style="animation: iconGridSlideIn 0.15s ease-out"
>
{#if showFilter}
<div class="border-b border-border px-3 py-2">
<input
type="text"
bind:value={filterQuery}
placeholder={$t('common.search_filter') ?? 'Filter...'}
class="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
autofocus
/>
</div>
{/if}
<div
class="grid gap-1 p-1.5"
style:grid-template-columns="repeat({columns}, 1fr)"
>
{#each filteredItems as item (item.value)}
<button
type="button"
class="flex flex-col items-center gap-1 rounded-md px-2 py-2.5 text-center transition-colors
{item.value === value
? 'border-2 border-primary bg-primary/10 text-foreground'
: 'border-2 border-transparent hover:bg-accent text-foreground'}"
onclick={() => select(item)}
>
<span class="flex h-6 w-6 items-center justify-center">
<span class="text-lg">{item.icon}</span>
</span>
<span class="text-xs font-medium leading-tight">{item.label}</span>
{#if item.desc}
<span class="text-[10px] leading-tight text-muted-foreground">{item.desc}</span>
{/if}
</button>
{/each}
{#if filteredItems.length === 0}
<div class="col-span-full py-4 text-center text-xs text-muted-foreground">
{$t('search.no_results', { values: { query: filterQuery } })}
</div>
{/if}
</div>
</div>
{/if}
<style>
@keyframes iconGridSlideIn {
from {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
</style>