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/.
242 lines
6.4 KiB
Svelte
242 lines
6.4 KiB
Svelte
<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 = $state<HTMLDivElement>();
|
|
let inputEl = $state<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(() => {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
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-xl border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors hover:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
|
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-[1.4rem] border border-border bg-popover shadow-[var(--shadow-lift)]"
|
|
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>
|