Files
web-app-launcher/src/lib/components/ui/IconPickerButton.svelte
T
alexei.dolgolyov 5dcadd1c20 feat(ui): migrate entire UI to "Cozy Home" design
Warm, friendly redesign replacing the generic cold-shadcn look. Built as a
swappable token bundle so other presets can be added later; dark mode and the
user-tunable accent hue are retained.

Foundation
- app.css: warm cream (light) + "dusk" (dark) token system; terracotta accent
  (default hue 16); pastel --room-* palette; vivid --status-* (dots/bars) plus
  AA-legible --status-*-ink (text); soft warm shadows; --radius 1rem; font tokens
- Fonts: Fraunces (display) + Figtree (body), self-hosted in static/fonts
  (no Google CDN) so offline/LAN installs work; system-ui fallbacks kept
- h1/h2/h3 render in Fraunces via base layer

Chrome and surfaces
- Sidebar, Header, home, AppCard/BoardCard, BoardHeader, sections, favorites
- 29 widgets + integration renderers: cozy card shells, room-palette charts
- Default background is a static warm "cozy" glow (mesh demoted, rAF gated on
  prefers-reduced-motion)

System-wide
- Status colors tokenized (no raw bg/text-*-500 or status hex); success/warning
  to status tokens, categorical to room palette, errors to destructive
- Inputs rounded-xl; buttons rounded-xl; cards/dialogs rounded-[1.4rem];
  soft-shadow vocabulary only; focus-visible:ring-primary/30
- Forms, admin tables (now cozy cards), dialogs, popovers, auth screens

a11y: reduced-motion guards; darker status "ink" text for AA on cream.
Known tradeoff: terracotta primary + white button text ~2.96:1 (signature color,
user-tunable).

Verified: svelte-check 0/0, build ok, 274 tests pass, eslint 0 errors.
Design refs + system sheet in design-mockups/.
2026-05-27 23:04:47 +03:00

170 lines
6.8 KiB
Svelte

<script lang="ts">
import { t } from 'svelte-i18n';
import DynamicIcon from './DynamicIcon.svelte';
import { tick } from 'svelte';
interface Props {
value: string;
onchange: (value: string) => void;
placeholder?: string;
size?: 'sm' | 'md';
}
let { value = $bindable(), onchange, placeholder, size = 'md' }: Props = $props();
let open = $state(false);
let query = $state('');
let searchInput: HTMLInputElement | undefined = $state();
let containerEl: HTMLDivElement | undefined = $state();
// Common lucide icon names for quick selection
const commonIcons = [
'home', 'star', 'heart', 'bookmark', 'folder', 'file', 'settings', 'search',
'user', 'users', 'mail', 'phone', 'calendar', 'clock', 'camera', 'image',
'music', 'video', 'monitor', 'smartphone', 'tablet', 'laptop', 'server', 'database',
'cloud', 'globe', 'map', 'compass', 'navigation', 'flag', 'tag', 'hash',
'lock', 'unlock', 'shield', 'key', 'eye', 'bell', 'megaphone', 'message-circle',
'terminal', 'code', 'git-branch', 'git-commit', 'cpu', 'hard-drive', 'wifi', 'bluetooth',
'battery', 'zap', 'sun', 'moon', 'thermometer', 'droplet', 'wind', 'umbrella',
'shopping-cart', 'credit-card', 'dollar-sign', 'trending-up', 'bar-chart', 'pie-chart',
'activity', 'award', 'target', 'crosshair', 'layers', 'layout', 'grid', 'list',
'package', 'box', 'archive', 'trash', 'download', 'upload', 'link', 'external-link',
'check', 'x', 'plus', 'minus', 'alert-circle', 'info', 'help-circle', 'alert-triangle',
'play', 'pause', 'skip-forward', 'volume-2', 'headphones', 'radio', 'tv', 'film',
'book', 'book-open', 'clipboard', 'pen-tool', 'edit', 'scissors', 'copy', 'save',
'printer', 'share', 'send', 'inbox', 'paperclip', 'at-sign', 'rss', 'wifi',
'rocket', 'flame', 'sparkles', 'wand', 'palette', 'brush', 'pipette', 'ruler',
'truck', 'car', 'bike', 'plane', 'ship', 'train', 'bus', 'building',
'gamepad', 'puzzle', 'dice-1', 'trophy', 'medal', 'crown', 'gem', 'gift'
];
const filteredIcons = $derived(
query.trim()
? commonIcons.filter((name) => name.includes(query.toLowerCase()))
: commonIcons
);
function toggleOpen() {
open = !open;
if (open) {
query = '';
tick().then(() => searchInput?.focus());
}
}
function selectIcon(name: string) {
value = name;
onchange(name);
open = false;
}
function clearIcon() {
value = '';
onchange('');
open = false;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') open = false;
}
function handleClickOutside(e: MouseEvent) {
if (open && containerEl && !containerEl.contains(e.target as Node)) {
open = false;
}
}
</script>
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
<div class="relative" bind:this={containerEl}>
<!-- Trigger button -->
<button
type="button"
onclick={toggleOpen}
class="flex items-center gap-1.5 rounded-xl border border-input bg-background transition-colors hover:bg-accent
{size === 'sm' ? 'px-2 py-1' : 'px-3 py-2'}"
title={$t('app.icon') ?? 'Select icon'}
>
{#if value}
<DynamicIcon name={value} size={size === 'sm' ? 14 : 18} />
<span class="text-xs text-muted-foreground">{value}</span>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" width={size === 'sm' ? 14 : 18} height={size === 'sm' ? 14 : 18} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground">
<rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /><rect x="3" y="14" width="7" height="7" />
</svg>
<span class="text-xs text-muted-foreground">{placeholder ?? ($t('app.icon') ?? 'Icon')}</span>
{/if}
</button>
<!-- Popover rendered as fixed overlay to avoid layout overflow -->
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-50"
onclick={(e) => { if (e.target === e.currentTarget) open = false; }}
>
<div class="fixed left-1/2 top-1/2 z-50 w-80 -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-card p-3 shadow-[var(--shadow-lift)]">
<!-- Search -->
<div class="relative mb-2">
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
bind:this={searchInput}
type="text"
bind:value={query}
placeholder={$t('common.search') ?? 'Search icons...'}
class="w-full rounded-xl border border-input bg-background py-1.5 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
/>
</div>
<!-- Clear button -->
{#if value}
<button
type="button"
onclick={clearIcon}
class="mb-2 flex w-full items-center gap-1.5 rounded-lg px-2 py-1 text-xs text-destructive transition-colors hover:bg-destructive/10"
>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
{$t('common.clear') ?? 'Clear icon'}
</button>
{/if}
<!-- Icon grid -->
<div class="max-h-48 overflow-y-auto">
{#if filteredIcons.length === 0}
<p class="py-4 text-center text-xs text-muted-foreground">{$t('common.no_results') ?? 'No matching icons'}</p>
{:else}
<div class="grid grid-cols-8 gap-0.5">
{#each filteredIcons as iconName (iconName)}
<button
type="button"
onclick={() => selectIcon(iconName)}
class="flex items-center justify-center rounded-lg p-1.5 transition-colors hover:bg-accent
{value === iconName ? 'bg-primary/10 text-primary ring-1 ring-primary/30' : 'text-foreground'}"
title={iconName}
>
<DynamicIcon name={iconName} size={16} />
</button>
{/each}
</div>
{/if}
</div>
<!-- Manual input fallback -->
<div class="mt-2 border-t border-border pt-2">
<input
type="text"
value={value}
oninput={(e) => { const v = (e.target as HTMLInputElement).value; value = v; onchange(v); }}
placeholder={$t('app.icon_manual') ?? 'Or type icon name...'}
class="w-full rounded-xl border border-input bg-background px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
/>
</div>
</div>
</div>
{/if}
</div>