feat(manacore/web): add inline QuickEventPopover for calendar

Replace the full-screen modal with a compact floating popover that
appears directly at the drag-to-create position in the week grid.
Includes title, time display, location field, and "Weitere Optionen"
link to expand into the full EventForm modal. Mobile: bottom sheet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 14:12:01 +02:00
parent 983da8540e
commit 3d124f04a4
3 changed files with 378 additions and 7 deletions

View file

@ -0,0 +1,322 @@
<script lang="ts">
import { getContext, onMount, tick } from 'svelte';
import { getDefaultCalendar, getCalendarColor } from '../queries';
import type { Calendar } from '../types';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
import { X } from '@manacore/shared-icons';
interface Props {
startTime: Date;
endTime: Date;
position: { x: number; y: number };
onSave: (data: {
title: string;
calendarId: string;
startTime: string;
endTime: string;
isAllDay: boolean;
location: string | null;
}) => void;
onClose: () => void;
onExpand?: () => void;
}
let { startTime, endTime, position, onSave, onClose, onExpand }: Props = $props();
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
let title = $state('');
let location = $state('');
let titleInput: HTMLInputElement;
let popoverEl: HTMLDivElement;
// Calculated popover position (adjusted to stay in viewport)
let popoverPos = $state({ top: 0, left: 0 });
const defaultCalendar = $derived(getDefaultCalendar(calendarsCtx.value));
const calendarColor = $derived(getCalendarColor(calendarsCtx.value, defaultCalendar?.id || ''));
const timeLabel = $derived(
`${format(startTime, 'EE d. MMM', { locale: de })} ${format(startTime, 'HH:mm')} ${format(endTime, 'HH:mm')}`
);
function handleSubmit() {
if (!title.trim()) return;
onSave({
title: title.trim(),
calendarId: defaultCalendar?.id || '',
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
isAllDay: false,
location: location.trim() || null,
});
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
} else if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}
onMount(async () => {
await tick();
if (popoverEl) {
const rect = popoverEl.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = position.x + 12;
let top = position.y - rect.height / 2;
// Keep in viewport
if (left + rect.width > vw - 16) left = position.x - rect.width - 12;
if (left < 16) left = 16;
if (top < 16) top = 16;
if (top + rect.height > vh - 16) top = vh - rect.height - 16;
popoverPos = { top, left };
}
titleInput?.focus();
});
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="popover-backdrop" onclick={onClose}></div>
<!-- Popover -->
<div
bind:this={popoverEl}
class="popover"
style="top: {popoverPos.top}px; left: {popoverPos.left}px;"
role="dialog"
aria-label="Termin erstellen"
>
<!-- Color accent bar -->
<div class="accent-bar" style="background-color: {calendarColor};"></div>
<div class="popover-content">
<!-- Title input -->
<input
bind:this={titleInput}
bind:value={title}
type="text"
placeholder="Termin hinzufügen"
class="title-input"
/>
<!-- Time display -->
<div class="time-row">
<span class="time-label">{timeLabel}</span>
</div>
<!-- Location (optional) -->
<input bind:value={location} type="text" placeholder="Ort hinzufügen" class="location-input" />
<!-- Actions -->
<div class="action-row">
{#if onExpand}
<button type="button" onclick={onExpand} class="expand-btn"> Weitere Optionen </button>
{/if}
<div class="action-right">
<button type="button" onclick={onClose} class="cancel-btn"> Abbrechen </button>
<button
type="button"
onclick={handleSubmit}
disabled={!title.trim()}
class="save-btn"
style="background-color: {calendarColor};"
>
Speichern
</button>
</div>
</div>
</div>
</div>
<style>
.popover-backdrop {
position: fixed;
inset: 0;
z-index: 99;
}
.popover {
position: fixed;
z-index: 100;
width: 320px;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
box-shadow:
0 20px 40px -8px rgba(0, 0, 0, 0.2),
0 8px 16px -4px rgba(0, 0, 0, 0.1);
overflow: hidden;
animation: popover-in 120ms ease-out;
}
@keyframes popover-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.accent-bar {
height: 4px;
width: 100%;
}
.popover-content {
padding: 0.875rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.title-input {
width: 100%;
border: none;
background: none;
font-size: 1.0625rem;
font-weight: 600;
color: hsl(var(--color-foreground));
outline: none;
padding: 0;
}
.title-input::placeholder {
color: hsl(var(--color-muted-foreground) / 0.5);
font-weight: 400;
}
.time-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.time-label {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.location-input {
width: 100%;
border: none;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
background: none;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
outline: none;
padding: 0.25rem 0;
}
.location-input::placeholder {
color: hsl(var(--color-muted-foreground) / 0.4);
}
.location-input:focus {
border-bottom-color: hsl(var(--color-primary));
}
.action-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding-top: 0.25rem;
}
.action-right {
display: flex;
align-items: center;
gap: 0.375rem;
margin-left: auto;
}
.expand-btn {
font-size: 0.75rem;
color: hsl(var(--color-primary));
background: none;
border: none;
cursor: pointer;
padding: 0.25rem 0;
font-weight: 500;
}
.expand-btn:hover {
text-decoration: underline;
}
.cancel-btn {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
background: none;
border: none;
cursor: pointer;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
}
.cancel-btn:hover {
background: hsl(var(--color-muted));
}
.save-btn {
font-size: 0.8125rem;
font-weight: 600;
color: white;
border: none;
cursor: pointer;
padding: 0.375rem 1rem;
border-radius: 0.375rem;
transition: opacity 0.15s;
}
.save-btn:hover {
opacity: 0.9;
}
.save-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Mobile: bottom sheet */
@media (max-width: 640px) {
.popover {
position: fixed;
top: auto !important;
left: 0 !important;
right: 0;
bottom: 0;
width: 100%;
border-radius: 1rem 1rem 0 0;
animation: slide-up 200ms ease-out;
}
@keyframes slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.popover-backdrop {
background: rgba(0, 0, 0, 0.3);
}
}
</style>

View file

@ -33,7 +33,7 @@
interface Props {
onEventClick?: (event: CalendarEvent) => void;
onQuickCreate?: (startTime: Date, endTime: Date) => void;
onQuickCreate?: (startTime: Date, endTime: Date, position: { x: number; y: number }) => void;
}
let { onEventClick, onQuickCreate }: Props = $props();
@ -85,9 +85,9 @@
hourHeight: HOUR_HEIGHT_PX,
minutesToPercent,
isOtherOperationActive: () => eventDragDrop.isDragging || eventDragDrop.isResizing,
onCreateEnd: (startTime, endTime, _position) => {
onCreateEnd: (startTime, endTime, position) => {
if (onQuickCreate) {
onQuickCreate(startTime, endTime);
onQuickCreate(startTime, endTime, position);
}
},
}));

View file

@ -19,6 +19,7 @@
import AgendaView from '$lib/modules/calendar/components/AgendaView.svelte';
import EventDetailModal from '$lib/modules/calendar/components/EventDetailModal.svelte';
import EventForm from '$lib/modules/calendar/components/EventForm.svelte';
import QuickEventPopover from '$lib/modules/calendar/components/QuickEventPopover.svelte';
import { ShareNetwork } from '@manacore/shared-icons';
import { ShareModal } from '@manacore/shared-uload';
@ -70,6 +71,12 @@
let createStartTime = $state<Date | null>(null);
let createEndTime = $state<Date | null>(null);
// Quick create popover (inline in calendar grid)
let showQuickCreate = $state(false);
let quickCreateStart = $state<Date>(new Date());
let quickCreateEnd = $state<Date>(new Date());
let quickCreatePosition = $state({ x: 0, y: 0 });
function handleEventClick(event: CalendarEvent) {
selectedEvent = event;
}
@ -80,9 +87,39 @@
showCreateForm = true;
}
function handleQuickCreate(startTime: Date, endTime: Date) {
createStartTime = startTime;
createEndTime = endTime;
function handleQuickCreate(startTime: Date, endTime: Date, position: { x: number; y: number }) {
quickCreateStart = startTime;
quickCreateEnd = endTime;
quickCreatePosition = position;
showQuickCreate = true;
}
function handleQuickSave(data: {
title: string;
calendarId: string;
startTime: string;
endTime: string;
isAllDay: boolean;
location: string | null;
}) {
eventsStore.createEvent({
calendarId: data.calendarId,
title: data.title,
description: null,
startTime: data.startTime,
endTime: data.endTime,
isAllDay: data.isAllDay,
location: data.location,
recurrenceRule: null,
});
showQuickCreate = false;
}
function expandQuickCreate() {
// Transfer quick create data to full modal
createStartTime = quickCreateStart;
createEndTime = quickCreateEnd;
showQuickCreate = false;
showCreateForm = true;
}
@ -139,12 +176,24 @@
<DateStrip />
</div>
<!-- Quick Event Popover (inline in calendar grid) -->
{#if showQuickCreate}
<QuickEventPopover
startTime={quickCreateStart}
endTime={quickCreateEnd}
position={quickCreatePosition}
onSave={handleQuickSave}
onClose={() => (showQuickCreate = false)}
onExpand={expandQuickCreate}
/>
{/if}
<!-- Event Detail Modal -->
{#if selectedEvent}
<EventDetailModal event={selectedEvent} onClose={() => (selectedEvent = null)} />
{/if}
<!-- Create Event Modal -->
<!-- Create Event Modal (full form, via header button or "Weitere Optionen") -->
{#if showCreateForm}
<div class="modal-backdrop" role="presentation">
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->