5dcadd1c20
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/.
170 lines
6.8 KiB
Svelte
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>
|