Files
EZPPLauncher/src/lib/components/theme/ThemeIconPicker.svelte
T

394 lines
14 KiB
Svelte

<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import * as Popover from '$lib/components/ui/popover';
import {
getFullIconCatalog,
type IconPackKey,
type ThemeIconCatalogItem,
} from '$lib/themeIconCatalog';
import { DEFAULT_THEME_ICON_SET, type ThemeIconSet } from '$lib/themeIcons';
import ThemeIcon from './ThemeIcon.svelte';
import { Check, ChevronsUpDown, LayoutGrid, List, Search, X } from '@lucide/svelte';
let {
value = $bindable(undefined),
iconSet = DEFAULT_THEME_ICON_SET,
placeholder = 'Select icon...',
disabled = false,
allowNone = false,
onchange,
}: {
value?: string;
iconSet?: ThemeIconSet;
placeholder?: string;
disabled?: boolean;
allowNone?: boolean;
onchange?: (val: string | undefined) => void;
} = $props();
let open = $state(false);
let searchQuery = $state('');
let selectedPack = $state<IconPackKey | 'all'>('all');
let viewMode = $state<'grid' | 'list'>('grid');
let scrollContainer = $state<HTMLDivElement | null>(null);
let scrollTop = $state(0);
const containerHeight = 280;
const gridColumns = 5;
const gridRowHeight = 54;
const listRowHeight = 34;
const overscan = 4;
const rowHeight = $derived(viewMode === 'grid' ? gridRowHeight : listRowHeight);
const catalog = getFullIconCatalog();
const packTabs: { key: IconPackKey | 'all'; label: string; count: number }[] = [
{ key: 'all', label: 'All', count: catalog.length },
{ key: 'launcher', label: 'Launcher', count: 47 },
{ key: 'lucide', label: 'Lucide', count: 2078 },
{ key: 'phosphor', label: 'Phosphor', count: 9161 },
{ key: 'tabler', label: 'Tabler', count: 6305 },
];
const filteredItems = $derived.by(() => {
let items = catalog;
if (selectedPack !== 'all') {
items = items.filter((it) => it.pack === selectedPack);
}
const q = searchQuery.trim().toLowerCase();
if (q) {
items = items.filter(
(it) => it.name.toLowerCase().includes(q) || it.id.toLowerCase().includes(q)
);
}
return items;
});
const numRows = $derived(
viewMode === 'grid' ? Math.ceil(filteredItems.length / gridColumns) : filteredItems.length
);
const totalHeight = $derived(numRows * rowHeight);
const startIndex = $derived(Math.max(0, Math.floor(scrollTop / rowHeight) - overscan));
const endIndex = $derived(
Math.min(numRows, Math.ceil((scrollTop + containerHeight) / rowHeight) + overscan)
);
type VirtualRow = {
index: number;
items?: ThemeIconCatalogItem[];
item?: ThemeIconCatalogItem;
};
const visibleRows = $derived.by(() => {
const rows: VirtualRow[] = [];
if (viewMode === 'grid') {
for (let i = startIndex; i < endIndex; i++) {
const rowItems = filteredItems.slice(i * gridColumns, (i + 1) * gridColumns);
rows.push({ index: i, items: rowItems });
}
} else {
for (let i = startIndex; i < endIndex; i++) {
const item = filteredItems[i];
if (item) {
rows.push({ index: i, item });
}
}
}
return rows;
});
$effect(() => {
// When search, pack, or view mode changes, reset scroll to top
void searchQuery;
void selectedPack;
void viewMode;
if (scrollContainer) {
scrollContainer.scrollTop = 0;
scrollTop = 0;
}
});
function selectIcon(id: string | undefined) {
if (id === 'none' || !id) {
value = undefined;
onchange?.(undefined);
} else {
value = id;
onchange?.(id);
}
open = false;
}
function displayName(val: string | undefined): string {
if (!val || val === 'none') return 'None';
if (val.includes(':')) {
return val.slice(val.indexOf(':') + 1);
}
return val;
}
function displayPack(val: string | undefined): string | null {
if (!val || val === 'none') return null;
if (val.startsWith('lucide:')) return 'Lucide';
if (val.startsWith('ph:')) return 'Phosphor';
if (val.startsWith('tabler:')) return 'Tabler';
return 'Launcher';
}
</script>
<Popover.Root bind:open>
<Popover.Trigger
{disabled}
class="flex h-8 w-full items-center justify-between gap-2 rounded-md border border-theme-800 bg-theme-950 px-2.5 text-xs text-theme-50 transition-colors hover:bg-theme-900/60 focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
>
{#if !value || value === 'none'}
<span class="flex items-center gap-2 text-muted-foreground truncate">
<span
class="flex size-4 items-center justify-center rounded border border-dashed border-theme-700 text-[10px]"
>✕</span
>
<span>{allowNone ? 'None' : placeholder}</span>
</span>
{:else}
<span class="flex items-center gap-2 min-w-0 truncate">
<span
class="flex size-5 shrink-0 items-center justify-center rounded bg-theme-900 border border-theme-800 text-theme-200"
>
<ThemeIcon name={value} set={iconSet} size={15} />
</span>
<span class="truncate font-mono text-xs text-theme-100">{displayName(value)}</span>
{#if displayPack(value)}
<span
class="rounded bg-theme-800/80 px-1 py-0.5 text-[8px] uppercase tracking-wide text-muted-foreground shrink-0"
>
{displayPack(value)}
</span>
{/if}
</span>
{/if}
<ChevronsUpDown class="size-3.5 shrink-0 text-muted-foreground opacity-60" />
</Popover.Trigger>
<Popover.Content
align="start"
side="bottom"
sideOffset={4}
class="w-[360px] max-w-[95vw] rounded-lg border border-theme-800 bg-theme-950 p-0 text-theme-50 shadow-2xl"
>
<!-- Search Bar -->
<div class="border-b border-theme-800/80 p-2">
<div class="relative flex items-center">
<Search class="absolute left-2.5 size-3.5 text-muted-foreground pointer-events-none" />
<Input
bind:value={searchQuery}
placeholder="Search 17,000+ icons..."
class="h-8 pl-8 pr-7 text-xs border-theme-800 bg-theme-900/60 placeholder:text-muted-foreground"
autofocus
/>
{#if searchQuery}
<button
type="button"
class="absolute right-2 text-muted-foreground hover:text-theme-100 transition-colors"
onclick={() => (searchQuery = '')}
>
<X class="size-3.5" />
</button>
{/if}
</div>
</div>
<!-- Pack Filter Tabs & View Toggle -->
<div
class="flex items-center justify-between gap-1 border-b border-theme-800/60 bg-theme-900/30 px-2 py-1.5"
>
<div class="flex items-center gap-1 overflow-x-auto no-scrollbar py-0.5">
{#each packTabs as tab (tab.key)}
<button
type="button"
class="shrink-0 rounded px-2 py-0.5 text-[10px] font-medium transition-colors {selectedPack ===
tab.key
? 'bg-primary/20 text-primary border border-primary/40 font-semibold'
: 'text-muted-foreground hover:bg-theme-800/60 hover:text-theme-200 border border-transparent'}"
onclick={() => (selectedPack = tab.key)}
>
{tab.label}
</button>
{/each}
</div>
<div class="flex items-center gap-0.5 shrink-0 pl-1 border-l border-theme-800/60">
<button
type="button"
class="rounded p-1 text-muted-foreground hover:text-theme-100 transition-colors {viewMode ===
'grid'
? 'bg-theme-800 text-theme-100'
: ''}"
title="Grid view"
onclick={() => (viewMode = 'grid')}
>
<LayoutGrid class="size-3.5" />
</button>
<button
type="button"
class="rounded p-1 text-muted-foreground hover:text-theme-100 transition-colors {viewMode ===
'list'
? 'bg-theme-800 text-theme-100'
: ''}"
title="List view"
onclick={() => (viewMode = 'list')}
>
<List class="size-3.5" />
</button>
</div>
</div>
<!-- Allow "None" option if requested -->
{#if allowNone}
<div class="border-b border-theme-800/50 px-2 py-1 bg-theme-900/20">
<button
type="button"
class="flex w-full items-center justify-between rounded px-2 py-1 text-xs transition-colors {value ===
'none' || !value
? 'bg-primary/20 text-primary font-medium'
: 'text-muted-foreground hover:bg-theme-800/60 hover:text-theme-100'}"
onclick={() => selectIcon('none')}
>
<span class="flex items-center gap-2">
<span
class="flex size-4 items-center justify-center rounded border border-dashed border-theme-700 text-[10px]"
>✕</span
>
<span>None (No icon)</span>
</span>
{#if value === 'none' || !value}
<Check class="size-3 text-primary" />
{/if}
</button>
</div>
{/if}
<!-- Virtual Scrolling List/Grid -->
<div
bind:this={scrollContainer}
class="overflow-y-auto px-2 py-1 select-none"
style="height: {containerHeight}px;"
onscroll={(e) => {
scrollTop = (e.currentTarget as HTMLElement).scrollTop;
}}
>
{#if filteredItems.length === 0}
<div
class="flex flex-col items-center justify-center py-12 text-center text-xs text-muted-foreground"
>
<p>No icons found</p>
{#if searchQuery}
<p class="text-[10px] text-muted-foreground/80 mt-0.5 font-mono">"{searchQuery}"</p>
{/if}
<Button
variant="ghost"
size="sm"
class="mt-3 h-7 text-[10px]"
onclick={() => {
searchQuery = '';
selectedPack = 'all';
}}
>
Clear filters
</Button>
</div>
{:else}
<div style="height: {totalHeight}px; position: relative;">
<div
style="position: absolute; top: 0; left: 0; right: 0; transform: translateY({startIndex *
rowHeight}px);"
>
{#if viewMode === 'grid'}
{#each visibleRows as row (row.index)}
<div class="grid grid-cols-5 gap-1.5" style="height: {rowHeight}px;">
{#each row.items ?? [] as item (item.id)}
{@const isSelected =
item.id === value || (item.pack === 'launcher' && item.name === value)}
<button
type="button"
class="group relative flex flex-col items-center justify-center rounded-md p-1 border transition-all text-center
{isSelected
? 'bg-primary/20 border-primary text-primary shadow-xs'
: 'border-transparent bg-theme-900/40 hover:bg-theme-800/80 hover:border-theme-700 text-theme-200 hover:text-theme-50'}"
title="{item.name} ({item.packLabel})"
onclick={() => selectIcon(item.id)}
>
<div class="flex size-5 items-center justify-center shrink-0">
<ThemeIcon name={item.id} set={iconSet} size={18} />
</div>
<span
class="mt-0.5 w-full truncate text-[9px] font-mono leading-tight opacity-75 group-hover:opacity-100"
>
{item.name}
</span>
{#if isSelected}
<div
class="absolute top-0.5 right-0.5 size-1.5 rounded-full bg-primary"
></div>
{/if}
</button>
{/each}
</div>
{/each}
{:else}
{#each visibleRows as row (row.index)}
{#if row.item}
{@const item = row.item}
{@const isSelected =
item.id === value || (item.pack === 'launcher' && item.name === value)}
<div style="height: {rowHeight}px;" class="flex items-center">
<button
type="button"
class="flex w-full h-7 items-center justify-between rounded px-2 text-xs transition-colors
{isSelected
? 'bg-primary/20 text-primary font-medium'
: 'text-theme-200 hover:bg-theme-800/70 hover:text-theme-50'}"
title="{item.name} ({item.packLabel})"
onclick={() => selectIcon(item.id)}
>
<div class="flex items-center gap-2 min-w-0">
<div class="flex size-4.5 items-center justify-center shrink-0">
<ThemeIcon name={item.id} set={iconSet} size={16} />
</div>
<span class="truncate font-mono text-[11px]">{item.name}</span>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<span
class="text-[9px] uppercase px-1 py-0.2 rounded bg-theme-900/80 border border-theme-800/80 text-muted-foreground font-mono"
>
{item.packLabel}
</span>
{#if isSelected}
<Check class="size-3 text-primary" />
{/if}
</div>
</button>
</div>
{/if}
{/each}
{/if}
</div>
</div>
{/if}
</div>
<!-- Footer Stats Bar -->
<div
class="flex items-center justify-between border-t border-theme-800/70 bg-theme-900/40 px-3 py-1.5 text-[10px] text-muted-foreground"
>
<span>{filteredItems.length.toLocaleString()} icons</span>
<span class="text-[9px]"
>Theme pack: <span class="capitalize text-theme-200">{iconSet}</span></span
>
</div>
</Popover.Content>
</Popover.Root>