feat(app-form): icon picker, tag/category autocomplete, typography

- Replace AppIconPicker text input with visual IconPickerButton for
  lucide icons (grid with search)
- Add AutocompleteInput component for category field with existing
  category suggestions
- Add TagsInput component for tags field with tag pills, autocomplete
  from existing tags, and keyboard navigation
- Add GET /api/apps/suggestions endpoint returning all categories/tags
- Add getAllTags() to appService (merges Tag model + comma-separated)
- Install @tailwindcss/typography plugin to fix prose rendering
  (headings, lists, blockquotes now render in Note/Markdown widgets)
- Fix note widget validator test for new html format
This commit is contained in:
2026-04-03 00:24:08 +03:00
parent a6b09aae9c
commit c5f5f84c79
10 changed files with 405 additions and 28 deletions
@@ -0,0 +1,109 @@
<script lang="ts">
import { tick } from 'svelte';
interface Props {
id?: string;
name?: string;
value: string;
suggestions: string[];
placeholder?: string;
class?: string;
}
let {
id,
name,
value = $bindable(),
suggestions,
placeholder = '',
class: className = ''
}: Props = $props();
let open = $state(false);
let highlightIdx = $state(-1);
let inputEl: HTMLInputElement | undefined = $state();
let containerEl: HTMLDivElement | undefined = $state();
const filtered = $derived.by(() => {
const q = value.trim().toLowerCase();
if (!q) return suggestions;
return suggestions.filter((s) => s.toLowerCase().includes(q));
});
function handleInput() {
open = true;
highlightIdx = -1;
}
function handleFocus() {
open = true;
}
function selectItem(item: string) {
value = item;
open = false;
inputEl?.focus();
}
function handleKeydown(e: KeyboardEvent) {
if (!open || filtered.length === 0) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
open = true;
e.preventDefault();
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightIdx = (highlightIdx + 1) % filtered.length;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightIdx = highlightIdx <= 0 ? filtered.length - 1 : highlightIdx - 1;
} else if (e.key === 'Enter' && highlightIdx >= 0) {
e.preventDefault();
selectItem(filtered[highlightIdx]);
} else if (e.key === 'Escape') {
open = false;
}
}
function handleClickOutside(e: MouseEvent) {
if (containerEl && !containerEl.contains(e.target as Node)) {
open = false;
}
}
</script>
<svelte:window onclick={handleClickOutside} />
<div class="relative" bind:this={containerEl}>
<input
{id}
{name}
type="text"
bind:this={inputEl}
bind:value
oninput={handleInput}
onfocus={handleFocus}
onkeydown={handleKeydown}
{placeholder}
class={className}
autocomplete="off"
/>
{#if open && filtered.length > 0}
<div class="absolute left-0 top-full z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-border bg-card shadow-lg">
{#each filtered as item, i}
<button
type="button"
onclick={() => selectItem(item)}
class="flex w-full items-center px-3 py-1.5 text-left text-sm text-foreground transition-colors
{i === highlightIdx ? 'bg-accent' : 'hover:bg-accent/50'}"
>
{item}
</button>
{/each}
</div>
{/if}
</div>
+146
View File
@@ -0,0 +1,146 @@
<script lang="ts">
import { t } from 'svelte-i18n';
interface Props {
id?: string;
name?: string;
value: string;
suggestions: string[];
placeholder?: string;
class?: string;
}
let {
id,
name,
value = $bindable(),
suggestions,
placeholder = '',
class: className = ''
}: Props = $props();
let open = $state(false);
let highlightIdx = $state(-1);
let inputEl: HTMLInputElement | undefined = $state();
let containerEl: HTMLDivElement | undefined = $state();
// Parse comma-separated tags
const tags = $derived(value.split(',').map((t) => t.trim()).filter(Boolean));
// Get the current partial tag being typed (after last comma)
const currentPartial = $derived.by(() => {
const parts = value.split(',');
return parts[parts.length - 1]?.trim() ?? '';
});
// Filter suggestions: exclude already-used tags, match partial
const filtered = $derived.by(() => {
const used = new Set(tags.map((t) => t.toLowerCase()));
const q = currentPartial.toLowerCase();
return suggestions
.filter((s) => !used.has(s.toLowerCase()))
.filter((s) => !q || s.toLowerCase().includes(q));
});
function handleInput() {
open = true;
highlightIdx = -1;
}
function handleFocus() {
open = true;
}
function selectItem(item: string) {
// Replace the current partial with the selected tag
const parts = value.split(',').map((p) => p.trim());
parts[parts.length - 1] = item;
value = parts.join(',') + ',';
open = false;
inputEl?.focus();
}
function removeTag(tag: string) {
const newTags = tags.filter((t) => t !== tag);
value = newTags.join(',');
}
function handleKeydown(e: KeyboardEvent) {
if (!open || filtered.length === 0) {
if (e.key === 'ArrowDown') {
open = true;
e.preventDefault();
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightIdx = (highlightIdx + 1) % filtered.length;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightIdx = highlightIdx <= 0 ? filtered.length - 1 : highlightIdx - 1;
} else if (e.key === 'Enter' && highlightIdx >= 0) {
e.preventDefault();
selectItem(filtered[highlightIdx]);
} else if (e.key === 'Escape') {
open = false;
}
}
function handleClickOutside(e: MouseEvent) {
if (containerEl && !containerEl.contains(e.target as Node)) {
open = false;
}
}
</script>
<svelte:window onclick={handleClickOutside} />
<div class="relative" bind:this={containerEl}>
<!-- Tag pills -->
{#if tags.length > 0}
<div class="mb-1.5 flex flex-wrap gap-1">
{#each tags as tag}
<span class="flex items-center gap-1 rounded-md bg-primary/10 px-2 py-0.5 text-xs text-primary">
{tag}
<button
type="button"
onclick={() => removeTag(tag)}
class="text-primary/60 hover:text-primary"
aria-label="Remove {tag}"
>&times;</button>
</span>
{/each}
</div>
{/if}
<input
{id}
{name}
type="text"
bind:this={inputEl}
bind:value
oninput={handleInput}
onfocus={handleFocus}
onkeydown={handleKeydown}
{placeholder}
class={className}
autocomplete="off"
/>
{#if open && filtered.length > 0}
<div class="absolute left-0 top-full z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-border bg-card shadow-lg">
{#each filtered as item, i}
<button
type="button"
onclick={() => selectItem(item)}
class="flex w-full items-center px-3 py-1.5 text-left text-sm text-foreground transition-colors
{i === highlightIdx ? 'bg-accent' : 'hover:bg-accent/50'}"
>
{item}
</button>
{/each}
</div>
{/if}
</div>