feat(shared-ui): locale-aware highlighting + success feedback for InputBar

1. Extract hardcoded German highlight patterns into locale-specific sets
   (de, en, fr, it, es). InputBar accepts `locale` or custom
   `highlightPatterns` prop, defaulting to German for backward compat.

2. Add visual success feedback after creating: input bar flashes green
   with a checkmark icon for 1.2s, confirming the action was successful.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 20:55:31 +01:00
parent 45db42720c
commit 9edd1c6e2e
5 changed files with 227 additions and 62 deletions

View file

@ -398,6 +398,7 @@
onParseCreate={handleParseCreate}
createText="Erstellen"
deferSearch={true}
locale={$locale || 'de'}
appIcon="todo"
hasFabRight={true}
bottomOffset={isPillNavCollapsed ? '16px' : isFilterStripVisible ? '180px' : '110px'}

View file

@ -1,40 +1,15 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import type { QuickInputItem, CreatePreview } from './types';
import type { QuickInputItem, CreatePreview, HighlightPattern } from './types';
import InputBarContextMenu from './InputBarContextMenu.svelte';
import { getInputBarSettingsStore } from './inputBarSettings.svelte';
import { getHighlightPatterns } from './highlightPatterns';
// Settings store
const settingsStore = getInputBarSettingsStore();
// Syntax highlighting patterns for command keywords
interface HighlightPattern {
pattern: RegExp;
className: string;
}
const HIGHLIGHT_PATTERNS: HighlightPattern[] = [
// Priority keywords (Todo) - with specific colors per level
{ pattern: /(!{3,}|!?dringend)\b/gi, className: 'hl-priority-urgent' },
{ pattern: /(!{2}|!?wichtig)\b/gi, className: 'hl-priority-high' },
{ pattern: /!?normal\b/gi, className: 'hl-priority-medium' },
{ pattern: /!?sp[aä]ter\b/gi, className: 'hl-priority-low' },
// Tags
{ pattern: /#\w+/g, className: 'hl-tag' },
// Projects/Calendars/Companies (@reference)
{ pattern: /@\w+/g, className: 'hl-reference' },
// Date keywords
{
pattern:
/\b(heute|morgen|übermorgen|montag|dienstag|mittwoch|donnerstag|freitag|samstag|sonntag|nächsten?\s+\w+|in\s+\d+\s+tagen?)\b/gi,
className: 'hl-date',
},
// Time patterns
{ pattern: /\b(\d{1,2}:\d{2}|um\s+\d{1,2}(\s*uhr)?|\d{1,2}\s*uhr)\b/gi, className: 'hl-time' },
];
function highlightText(text: string): string {
function highlightText(text: string, patterns: HighlightPattern[]): string {
if (!text) return '';
let result = text;
@ -42,7 +17,7 @@
result = result.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Apply highlights (process in order, avoiding double-highlighting)
for (const { pattern, className } of HIGHLIGHT_PATTERNS) {
for (const { pattern, className } of patterns) {
result = result.replace(pattern, (match) => `<span class="${className}">${match}</span>`);
}
@ -92,6 +67,10 @@
onShowSyntaxHelp?: () => void;
/** Snippet for left action button (e.g., voice input) - rendered inside the input bar on the left */
leftAction?: Snippet;
/** Custom highlight patterns. If not provided, uses locale-based defaults. */
highlightPatterns?: HighlightPattern[];
/** Locale for syntax highlighting keywords (e.g., 'de', 'en'). Default: 'de'. */
locale?: string;
}
let {
@ -118,6 +97,8 @@
onShowShortcuts,
onShowSyntaxHelp,
leftAction,
highlightPatterns,
locale = 'de',
}: Props = $props();
// Use settings for autoFocus
@ -127,10 +108,12 @@
let results = $state<QuickInputItem[]>([]);
let loading = $state(false);
let creating = $state(false);
let createSuccess = $state(false);
let selectedIndex = $state(0);
let showPanel = $state(false);
let isFocused = $state(false);
let searchTimeout: ReturnType<typeof setTimeout>;
let createSuccessTimeout: ReturnType<typeof setTimeout>;
let inputElement = $state<HTMLInputElement | null>(null);
// Whether search has been explicitly triggered in deferred mode
let searchTriggered = $state(false);
@ -145,9 +128,12 @@
searchQuery.trim() && onParseCreate ? onParseCreate(searchQuery) : null
);
// Resolve highlight patterns: custom prop > locale-based defaults
let effectivePatterns = $derived(highlightPatterns ?? getHighlightPatterns(locale));
// Highlighted text for overlay (respects syntax highlighting setting)
let highlightedQuery = $derived(
settingsStore.syntaxHighlighting ? highlightText(searchQuery) : searchQuery
settingsStore.syntaxHighlighting ? highlightText(searchQuery, effectivePatterns) : searchQuery
);
// Check if create option is selected (it's always first when available)
@ -253,11 +239,19 @@
selectedIndex = 0;
searchTriggered = false;
onSearchChange?.('', []);
// Show success feedback
creating = false;
createSuccess = true;
clearTimeout(createSuccessTimeout);
createSuccessTimeout = setTimeout(() => {
createSuccess = false;
}, 1200);
// Keep focus for rapid entry
inputElement?.focus();
} catch (error) {
console.error('Create error:', error);
} finally {
creating = false;
}
}
@ -495,7 +489,11 @@
<!-- Input Bar (always visible) -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="input-container" oncontextmenu={handleContextMenu}>
<div
class="input-container"
class:create-success={createSuccess}
oncontextmenu={handleContextMenu}
>
<!-- Left action slot (e.g., voice input button) -->
{#if leftAction}
<div class="left-action">
@ -503,39 +501,51 @@
</div>
{/if}
<div class="app-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if appIcon === 'check-square' || appIcon === 'todo'}
<div class="app-icon" class:success-icon={createSuccess}>
{#if createSuccess}
<!-- Checkmark icon -->
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
stroke-width="2.5"
d="M5 13l4 4L19 7"
/>
{:else if appIcon === 'calendar'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
{:else if appIcon === 'users' || appIcon === 'contacts'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
{:else}
<!-- Default search icon -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
{/if}
</svg>
</svg>
{:else}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if appIcon === 'check-square' || appIcon === 'todo'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
{:else if appIcon === 'calendar'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
{:else if appIcon === 'users' || appIcon === 'contacts'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
{:else}
<!-- Default search icon -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
{/if}
</svg>
{/if}
</div>
<div class="input-wrapper">
@ -656,6 +666,48 @@
0 0 0 2px hsl(var(--color-primary) / 0.25);
}
/* Success flash after creating */
.input-container.create-success {
border-color: hsl(var(--color-success, 142 71% 45%));
box-shadow:
0 4px 6px -1px hsl(var(--color-foreground) / 0.1),
0 2px 4px -1px hsl(var(--color-foreground) / 0.06),
0 0 0 2px hsl(var(--color-success, 142 71% 45%) / 0.3);
animation: success-flash 1.2s ease-out;
}
@keyframes success-flash {
0% {
border-color: hsl(var(--color-success, 142 71% 45%));
background: hsl(var(--color-success, 142 71% 45%) / 0.15);
}
40% {
background: hsl(var(--color-success, 142 71% 45%) / 0.08);
}
100% {
background: hsl(var(--color-surface) / 0.85);
}
}
.app-icon.success-icon {
color: hsl(var(--color-success, 142 71% 45%));
animation: success-check 0.4s ease-out;
}
@keyframes success-check {
0% {
transform: scale(0.5);
opacity: 0;
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.left-action {
display: flex;
align-items: center;

View file

@ -0,0 +1,104 @@
import type { HighlightPattern } from './types';
/** Shared patterns that work across all locales (symbols, not words) */
const UNIVERSAL_PATTERNS: HighlightPattern[] = [
// Priority shortcuts: !!! = urgent, !! = high
{ pattern: /!{3,}/g, className: 'hl-priority-urgent' },
{ pattern: /!{2}/g, className: 'hl-priority-high' },
// Tags
{ pattern: /#\w+/g, className: 'hl-tag' },
// Projects/Calendars/Companies (@reference)
{ pattern: /@\w+/g, className: 'hl-reference' },
// Time patterns (universal formats)
{ pattern: /\b\d{1,2}:\d{2}\b/g, className: 'hl-time' },
];
/** German date/priority keywords */
const DE_PATTERNS: HighlightPattern[] = [
{ pattern: /\b!?dringend\b/gi, className: 'hl-priority-urgent' },
{ pattern: /\b!?wichtig\b/gi, className: 'hl-priority-high' },
{ pattern: /\b!?normal\b/gi, className: 'hl-priority-medium' },
{ pattern: /\b!?sp[aä]ter\b/gi, className: 'hl-priority-low' },
{
pattern:
/\b(heute|morgen|übermorgen|montag|dienstag|mittwoch|donnerstag|freitag|samstag|sonntag|nächsten?\s+\w+|in\s+\d+\s+tagen?)\b/gi,
className: 'hl-date',
},
{ pattern: /\b(um\s+\d{1,2}(\s*uhr)?|\d{1,2}\s*uhr)\b/gi, className: 'hl-time' },
];
/** English date/priority keywords */
const EN_PATTERNS: HighlightPattern[] = [
{ pattern: /\b!?urgent\b/gi, className: 'hl-priority-urgent' },
{ pattern: /\b!?(important|high)\b/gi, className: 'hl-priority-high' },
{ pattern: /\b!?(normal|medium)\b/gi, className: 'hl-priority-medium' },
{ pattern: /\b!?(low|later)\b/gi, className: 'hl-priority-low' },
{
pattern:
/\b(today|tomorrow|yesterday|monday|tuesday|wednesday|thursday|friday|saturday|sunday|next\s+\w+|in\s+\d+\s+days?)\b/gi,
className: 'hl-date',
},
{ pattern: /\b(at\s+\d{1,2}(:\d{2})?\s*(am|pm)?)\b/gi, className: 'hl-time' },
];
/** French date/priority keywords */
const FR_PATTERNS: HighlightPattern[] = [
{ pattern: /\b!?urgent[e]?\b/gi, className: 'hl-priority-urgent' },
{ pattern: /\b!?(important[e]?|haut[e]?)\b/gi, className: 'hl-priority-high' },
{ pattern: /\b!?(normal[e]?|moyen(?:ne)?)\b/gi, className: 'hl-priority-medium' },
{ pattern: /\b!?(bas(?:se)?|plus\s+tard)\b/gi, className: 'hl-priority-low' },
{
pattern:
/\b(aujourd'hui|demain|après-demain|hier|lundi|mardi|mercredi|jeudi|vendredi|samedi|dimanche|prochain[e]?\s+\w+|dans\s+\d+\s+jours?)\b/gi,
className: 'hl-date',
},
{ pattern: /\b(à\s+\d{1,2}[h:]\d{0,2})\b/gi, className: 'hl-time' },
];
/** Italian date/priority keywords */
const IT_PATTERNS: HighlightPattern[] = [
{ pattern: /\b!?urgente\b/gi, className: 'hl-priority-urgent' },
{ pattern: /\b!?(importante|alto)\b/gi, className: 'hl-priority-high' },
{ pattern: /\b!?(normale|medio)\b/gi, className: 'hl-priority-medium' },
{ pattern: /\b!?(basso|dopo)\b/gi, className: 'hl-priority-low' },
{
pattern:
/\b(oggi|domani|dopodomani|ieri|luned[ìi]|marted[ìi]|mercoled[ìi]|gioved[ìi]|venerd[ìi]|sabato|domenica|prossim[oa]\s+\w+|tra\s+\d+\s+giorn[oi])\b/gi,
className: 'hl-date',
},
{ pattern: /\b(alle\s+\d{1,2}[.:]\d{0,2})\b/gi, className: 'hl-time' },
];
/** Spanish date/priority keywords */
const ES_PATTERNS: HighlightPattern[] = [
{ pattern: /\b!?urgente\b/gi, className: 'hl-priority-urgent' },
{ pattern: /\b!?(importante|alto)\b/gi, className: 'hl-priority-high' },
{ pattern: /\b!?(normal|medio)\b/gi, className: 'hl-priority-medium' },
{ pattern: /\b!?(bajo|luego)\b/gi, className: 'hl-priority-low' },
{
pattern:
/\b(hoy|mañana|pasado\s+mañana|ayer|lunes|martes|miércoles|jueves|viernes|sábado|domingo|próxim[oa]\s+\w+|en\s+\d+\s+d[ií]as?)\b/gi,
className: 'hl-date',
},
{ pattern: /\b(a\s+las?\s+\d{1,2}[.:]\d{0,2})\b/gi, className: 'hl-time' },
];
const LOCALE_PATTERNS: Record<string, HighlightPattern[]> = {
de: DE_PATTERNS,
en: EN_PATTERNS,
fr: FR_PATTERNS,
it: IT_PATTERNS,
es: ES_PATTERNS,
};
/**
* Get highlight patterns for a given locale.
* Returns universal patterns + locale-specific patterns.
* Falls back to German if locale is unknown.
*/
export function getHighlightPatterns(locale = 'de'): HighlightPattern[] {
// Normalize locale (e.g., "en-US" -> "en")
const lang = locale.split('-')[0].toLowerCase();
const localeSpecific = LOCALE_PATTERNS[lang] || LOCALE_PATTERNS['de'];
return [...UNIVERSAL_PATTERNS, ...localeSpecific];
}

View file

@ -3,7 +3,10 @@ export { default as InputBar } from './InputBar.svelte';
export { default as QuickInputBar } from './InputBar.svelte';
export { default as InputBarContextMenu } from './InputBarContextMenu.svelte';
export { default as InputBarHelpModal } from './InputBarHelpModal.svelte';
export type { QuickInputItem, QuickAction, CreatePreview } from './types';
export type { QuickInputItem, QuickAction, CreatePreview, HighlightPattern } from './types';
// Highlight patterns (locale-aware syntax highlighting)
export { getHighlightPatterns } from './highlightPatterns';
// Recent input history (tags, references)
export {

View file

@ -20,3 +20,8 @@ export interface CreatePreview {
title: string;
subtitle: string;
}
export interface HighlightPattern {
pattern: RegExp;
className: string;
}