feat(calendar): add search highlighting to calendar views

- Add searchStore for managing search state and event highlighting
- Integrate InputBar search with calendar view highlighting
- Dim non-matching events and highlight matching events during search
- Add search-highlighted and search-dimmed CSS classes to all views
- Adjust toolbar position for DateStrip component

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-12 11:57:55 +01:00
parent 7ff8213bd6
commit 51912a285d
7 changed files with 221 additions and 18 deletions

View file

@ -55,7 +55,7 @@
}
</script>
<PillToolbar position="bottom" bottomOffset="70px">
<PillToolbar position="bottom" bottomOffset="140px">
<!-- Calendar selector -->
<PillCalendarSelector direction="up" embedded={true} />

View file

@ -3,6 +3,7 @@
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { searchStore } from '$lib/stores/search.svelte';
import { todosStore, type Task } from '$lib/stores/todos.svelte';
import TaskBlock from './TaskBlock.svelte';
import { goto } from '$app/navigation';
@ -677,6 +678,8 @@
{#each headerAllDayEvents as event}
<button
class="all-day-event"
class:search-highlighted={searchStore.isEventHighlighted(event.id)}
class:search-dimmed={searchStore.isEventDimmed(event.id)}
style="background-color: {calendarsStore.getColor(event.calendarId)}"
onclick={(e) => handleEventClick(event, e)}
>
@ -719,6 +722,8 @@
{#each blockAllDayEvents as event}
<button
class="all-day-block-event"
class:search-highlighted={searchStore.isEventHighlighted(event.id)}
class:search-dimmed={searchStore.isEventDimmed(event.id)}
style="background-color: {calendarsStore.getColor(event.calendarId)}"
onclick={(e) => handleEventClick(event, e)}
>
@ -731,12 +736,16 @@
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
{@const isDraft = eventsStore.isDraftEvent(event.id)}
{@const isSearchHighlighted = searchStore.isEventHighlighted(event.id)}
{@const isSearchDimmed = searchStore.isEventDimmed(event.id)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="event-card"
class:dragging={isBeingDragged}
class:resizing={isBeingResized}
class:draft={isDraft}
class:search-highlighted={isSearchHighlighted}
class:search-dimmed={isSearchDimmed}
data-event-id={event.id}
style={isBeingDragged
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
@ -844,6 +853,18 @@
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
transition: opacity 0.15s ease;
}
.all-day-event.search-highlighted {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.3);
}
.all-day-event.search-dimmed {
opacity: 0.35;
filter: grayscale(0.3);
}
/* Block-style all-day events (displayed as full-day blocks in the grid) */
@ -870,6 +891,17 @@
opacity: 0.5;
}
.all-day-block-event.search-highlighted {
opacity: 0.6;
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
}
.all-day-block-event.search-dimmed {
opacity: 0.15;
filter: grayscale(0.5);
}
.all-day-block-event .event-title {
font-size: 0.875rem;
font-weight: 500;
@ -969,6 +1001,21 @@
animation: pulse-outline 1.5s ease-in-out infinite;
}
/* Search highlighting */
.event-card.search-highlighted {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
box-shadow:
0 0 0 4px hsl(var(--color-primary) / 0.3),
0 4px 12px rgba(0, 0, 0, 0.25);
z-index: 10;
}
.event-card.search-dimmed {
opacity: 0.35;
filter: grayscale(0.3);
}
@keyframes pulse-outline {
0%,
100% {

View file

@ -3,6 +3,7 @@
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { searchStore } from '$lib/stores/search.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import TodoDayCell from './TodoDayCell.svelte';
import { goto } from '$app/navigation';
@ -286,11 +287,15 @@
{#each getEventsForDay(day) as event}
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
{@const isDraft = eventsStore.isDraftEvent(event.id)}
{@const isSearchHighlighted = searchStore.isEventHighlighted(event.id)}
{@const isSearchDimmed = searchStore.isEventDimmed(event.id)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="event-pill"
class:dragging={isBeingDragged}
class:draft={isDraft}
class:search-highlighted={isSearchHighlighted}
class:search-dimmed={isSearchDimmed}
data-event-id={event.id}
style="background-color: {calendarsStore.getColor(event.calendarId)}"
onpointerdown={(e) => startDrag(event, e)}
@ -458,6 +463,20 @@
animation: pulse-outline 1.5s ease-in-out infinite;
}
/* Search highlighting */
.event-pill.search-highlighted {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.3);
z-index: 10;
transform: scale(1.02);
}
.event-pill.search-dimmed {
opacity: 0.35;
filter: grayscale(0.3);
}
@keyframes pulse-outline {
0%,
100% {

View file

@ -3,6 +3,7 @@
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { searchStore } from '$lib/stores/search.svelte';
import { todosStore, type Task } from '$lib/stores/todos.svelte';
import TaskBlock from './TaskBlock.svelte';
import { goto } from '$app/navigation';
@ -781,6 +782,8 @@
{#each getHeaderAllDayEventsForDay(day) as event}
<button
class="all-day-event"
class:search-highlighted={searchStore.isEventHighlighted(event.id)}
class:search-dimmed={searchStore.isEventDimmed(event.id)}
style="background-color: {calendarsStore.getColor(event.calendarId)}"
onclick={(e) => handleEventClick(event, e)}
title={event.title}
@ -841,6 +844,8 @@
{#each getBlockAllDayEventsForDay(day) as event (event.id)}
<button
class="all-day-block-event"
class:search-highlighted={searchStore.isEventHighlighted(event.id)}
class:search-dimmed={searchStore.isEventDimmed(event.id)}
style="background-color: {calendarsStore.getColor(event.calendarId)}"
onclick={(e) => handleEventClick(event, e)}
title={event.title}
@ -856,12 +861,16 @@
{@const isDraft = eventsStore.isDraftEvent(event.id)}
{@const isCrossDayDrag =
isBeingDragged && dragTargetDay && !isSameDay(day, dragTargetDay)}
{@const isSearchHighlighted = searchStore.isEventHighlighted(event.id)}
{@const isSearchDimmed = searchStore.isEventDimmed(event.id)}
<div
class="event-card"
class:dragging={isBeingDragged && !isCrossDayDrag}
class:dragging-source={isCrossDayDrag}
class:resizing={isBeingResized}
class:draft={isDraft}
class:search-highlighted={isSearchHighlighted}
class:search-dimmed={isSearchDimmed}
data-event-id={event.id}
style={isBeingDragged && !isCrossDayDrag
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
@ -995,6 +1004,18 @@
border: none;
cursor: pointer;
max-width: 100%;
transition: opacity 0.15s ease;
}
.all-day-event.search-highlighted {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.3);
}
.all-day-event.search-dimmed {
opacity: 0.35;
filter: grayscale(0.3);
}
.compact .all-day-event,
@ -1034,6 +1055,17 @@
opacity: 0.5;
}
.all-day-block-event.search-highlighted {
opacity: 0.6;
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
}
.all-day-block-event.search-dimmed {
opacity: 0.15;
filter: grayscale(0.5);
}
.all-day-block-event .event-title {
font-size: 0.75rem;
font-weight: 500;
@ -1214,6 +1246,21 @@
animation: pulse-outline 1.5s ease-in-out infinite;
}
/* Search highlighting */
.event-card.search-highlighted {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
box-shadow:
0 0 0 4px hsl(var(--color-primary) / 0.3),
0 4px 12px rgba(0, 0, 0, 0.25);
z-index: 10;
}
.event-card.search-dimmed {
opacity: 0.35;
filter: grayscale(0.3);
}
@keyframes pulse-outline {
0%,
100% {

View file

@ -3,6 +3,7 @@
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { searchStore } from '$lib/stores/search.svelte';
import { todosStore, type Task } from '$lib/stores/todos.svelte';
import TaskBlock from './TaskBlock.svelte';
import { goto } from '$app/navigation';
@ -818,6 +819,8 @@
{#each getHeaderAllDayEventsForDay(day) as event}
<button
class="all-day-event"
class:search-highlighted={searchStore.isEventHighlighted(event.id)}
class:search-dimmed={searchStore.isEventDimmed(event.id)}
style="background-color: {calendarsStore.getColor(event.calendarId)}"
onclick={() => goto(`/?event=${event.id}`)}
>
@ -875,6 +878,8 @@
{#each getBlockAllDayEventsForDay(day) as event (event.id)}
<button
class="all-day-block-event"
class:search-highlighted={searchStore.isEventHighlighted(event.id)}
class:search-dimmed={searchStore.isEventDimmed(event.id)}
style="background-color: {calendarsStore.getColor(event.calendarId)}"
onclick={() => goto(`/?event=${event.id}`)}
>
@ -889,12 +894,16 @@
{@const isDraft = eventsStore.isDraftEvent(event.id)}
{@const isCrossDayDrag =
isBeingDragged && dragTargetDay && !isSameDay(day, dragTargetDay)}
{@const isSearchHighlighted = searchStore.isEventHighlighted(event.id)}
{@const isSearchDimmed = searchStore.isEventDimmed(event.id)}
<div
class="event-card"
class:dragging={isBeingDragged && !isCrossDayDrag}
class:dragging-source={isCrossDayDrag}
class:resizing={isBeingResized}
class:draft={isDraft}
class:search-highlighted={isSearchHighlighted}
class:search-dimmed={isSearchDimmed}
data-event-id={event.id}
style={isBeingDragged && !isCrossDayDrag
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
@ -1028,6 +1037,18 @@
text-overflow: ellipsis;
border: none;
cursor: pointer;
transition: opacity 0.15s ease;
}
.all-day-event.search-highlighted {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.3);
}
.all-day-event.search-dimmed {
opacity: 0.35;
filter: grayscale(0.3);
}
/* Block-style all-day events (displayed as full-day blocks in the grid) */
@ -1054,6 +1075,17 @@
opacity: 0.5;
}
.all-day-block-event.search-highlighted {
opacity: 0.6;
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
}
.all-day-block-event.search-dimmed {
opacity: 0.15;
filter: grayscale(0.5);
}
.all-day-block-event .event-title {
font-size: 0.75rem;
font-weight: 500;
@ -1225,6 +1257,21 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Search highlighting */
.event-card.search-highlighted {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
box-shadow:
0 0 0 4px hsl(var(--color-primary) / 0.3),
0 4px 12px rgba(0, 0, 0, 0.25);
z-index: 10;
}
.event-card.search-dimmed {
opacity: 0.35;
filter: grayscale(0.3);
}
/* Task drag ghost */
.task-drag-ghost {
position: absolute;

View file

@ -0,0 +1,45 @@
/**
* Search Store - manages search state for highlighting events in calendar views
*/
interface SearchItem {
id: string;
[key: string]: unknown;
}
class SearchStore {
// Current search query
query = $state('');
// Event IDs that match the search
matchingEventIds = $state<Set<string>>(new Set());
// Whether search is active (user is typing in InputBar)
isSearching = $state(false);
// Set search query and matching items (events or any items with an id)
setSearch(query: string, matchingItems: SearchItem[]) {
this.query = query;
this.matchingEventIds = new Set(matchingItems.map((item) => item.id));
this.isSearching = query.trim().length > 0;
}
// Clear search
clear() {
this.query = '';
this.matchingEventIds = new Set();
this.isSearching = false;
}
// Check if an event matches the search
isEventHighlighted(eventId: string): boolean {
return this.isSearching && this.matchingEventIds.has(eventId);
}
// Check if an event should be dimmed (search active but event doesn't match)
isEventDimmed(eventId: string): boolean {
return this.isSearching && !this.matchingEventIds.has(eventId);
}
}
export const searchStore = new SearchStore();

View file

@ -8,7 +8,6 @@
PillNavItem,
PillDropdownItem,
QuickInputItem,
QuickAction,
CreatePreview,
} from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme';
@ -34,6 +33,7 @@
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { searchEvents } from '$lib/api/events';
import { searchStore } from '$lib/stores/search.svelte';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
import {
@ -49,19 +49,7 @@
let { children } = $props();
// QuickInputBar quick actions
const quickActions: QuickAction[] = [
{
id: 'today',
label: 'Heute',
icon: 'calendar',
onclick: () => viewStore.goToToday(),
},
{ id: 'agenda', label: 'Agenda', icon: 'list', href: '/agenda' },
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
];
// QuickInputBar search - search events
// InputBar search - search events
async function handleSearch(query: string): Promise<QuickInputItem[]> {
if (!query.trim()) return [];
@ -76,9 +64,19 @@
}
function handleSelect(item: QuickInputItem) {
searchStore.clear();
goto(`/event/${item.id}`);
}
// Update search store when search changes (for calendar view highlighting)
function handleSearchChange(query: string, results: QuickInputItem[]) {
if (!query.trim()) {
searchStore.clear();
} else {
searchStore.setSearch(query, results);
}
}
// QuickInputBar Quick-Create handlers
function handleParseCreate(query: string): CreatePreview | null {
if (!query.trim()) return null;
@ -347,11 +345,11 @@
</div>
</main>
<!-- Global Quick Input Bar -->
<!-- Global Input Bar -->
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
{quickActions}
onSearchChange={handleSearchChange}
placeholder="Neuer Termin oder suchen..."
emptyText="Keine Termine gefunden"
searchingText="Suche..."
@ -360,7 +358,7 @@
createText="Erstellen"
appIcon="calendar"
primaryColor="#3b82f6"
autoFocus={false}
autoFocus={true}
/>
</div>