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.
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { Search } from 'lucide-svelte';
|
||||
|
||||
export interface EntityPickerItem {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: string | null;
|
||||
desc?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: EntityPickerItem[];
|
||||
value: string;
|
||||
onchange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
allowNone?: boolean;
|
||||
noneLabel?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
items,
|
||||
value = $bindable(),
|
||||
onchange,
|
||||
placeholder = '',
|
||||
searchPlaceholder = '',
|
||||
allowNone = false,
|
||||
noneLabel = '—',
|
||||
name
|
||||
}: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let query = $state('');
|
||||
let highlightIdx = $state(0);
|
||||
let listEl: HTMLDivElement;
|
||||
let inputEl: HTMLInputElement;
|
||||
|
||||
const selectedItem = $derived(items.find((i) => i.value === value));
|
||||
|
||||
const allItems = $derived.by(() => {
|
||||
const result: (EntityPickerItem & { _isNone?: boolean })[] = [];
|
||||
if (allowNone) {
|
||||
result.push({ value: '', label: noneLabel, icon: null, desc: null, _isNone: true });
|
||||
}
|
||||
result.push(...items);
|
||||
return result;
|
||||
});
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
if (!query.trim()) return allItems;
|
||||
const q = query.toLowerCase().trim();
|
||||
return allItems.filter(
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) || (i.desc && i.desc.toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
// Reset highlight to current value when opened
|
||||
const idx = filtered.findIndex((i) => i.value === value);
|
||||
highlightIdx = idx >= 0 ? idx : 0;
|
||||
}
|
||||
});
|
||||
|
||||
function openPicker() {
|
||||
query = '';
|
||||
highlightIdx = 0;
|
||||
open = true;
|
||||
requestAnimationFrame(() => inputEl?.focus());
|
||||
}
|
||||
|
||||
function closePicker() {
|
||||
open = false;
|
||||
query = '';
|
||||
}
|
||||
|
||||
function selectItem(item: EntityPickerItem) {
|
||||
value = item.value;
|
||||
closePicker();
|
||||
onchange?.(item.value);
|
||||
}
|
||||
|
||||
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]) {
|
||||
selectItem(filtered[highlightIdx]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closePicker();
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function scrollHighlightIntoView() {
|
||||
requestAnimationFrame(() => {
|
||||
const el = listEl?.querySelector('.ep-highlight');
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) closePicker();
|
||||
}
|
||||
|
||||
// Reset highlight when query changes
|
||||
$effect(() => {
|
||||
query; // track
|
||||
highlightIdx = 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if name}
|
||||
<input type="hidden" {name} {value} />
|
||||
{/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 selectedItem}
|
||||
{#if selectedItem.icon}
|
||||
<span class="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<span>{selectedItem.icon}</span>
|
||||
</span>
|
||||
{/if}
|
||||
<span class="flex-1 truncate text-left">{selectedItem.label}</span>
|
||||
{:else if allowNone && !value}
|
||||
<span class="flex-1 text-left text-muted-foreground">{noneLabel}</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: epFadeIn 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: epSlideDown 0.2s cubic-bezier(0.16, 1, 0.3, 1)"
|
||||
role="dialog"
|
||||
aria-label={searchPlaceholder || 'Select entity'}
|
||||
>
|
||||
<!-- 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('admin.perm_search_placeholder') || 'Search...'}
|
||||
class="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
</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 + (item._isNone ? '_none' : ''))}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors
|
||||
{i === highlightIdx ? 'ep-highlight bg-accent' : 'hover:bg-accent/50'}
|
||||
{item.value === value && !item._isNone ? 'border-l-[3px] border-l-primary' : 'border-l-[3px] border-l-transparent'}"
|
||||
onclick={() => selectItem(item)}
|
||||
onmouseenter={() => (highlightIdx = i)}
|
||||
>
|
||||
{#if item.icon}
|
||||
<span
|
||||
class="flex h-5 w-5 shrink-0 items-center justify-center text-muted-foreground"
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
</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="border-t border-border px-3 py-1.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
↑↓ navigate · Enter select · Esc close
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes epFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes epSlideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-12px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<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">▾</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>
|
||||
Reference in New Issue
Block a user