feat(ui): add ConfirmationPopover component for inline confirmations

- Add new ConfirmationPopover wrapper component in shared-ui
- Uses portal pattern to escape overflow constraints
- Supports danger/warning/info variants with appropriate styling
- Uses elevation-3 for proper layering above overlays
- Integrate in QuickEventOverlay for delete confirmation
- Fix parseISO bug in QuickEventOverlay (was not imported)

🤖 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-14 15:09:16 +01:00
parent cab1905a2c
commit 5190b1449a
4 changed files with 434 additions and 22 deletions

View file

@ -11,7 +11,7 @@
EventAttendee,
} from '@calendar/shared';
import type { ContactSummary, ContactOrManual, ManualContactEntry } from '@manacore/shared-types';
import { ContactSelector, ContactAvatar } from '@manacore/shared-ui';
import { ContactSelector, ContactAvatar, ConfirmationPopover } from '@manacore/shared-ui';
import { Users } from 'lucide-svelte';
import { format, addMinutes } from 'date-fns';
import { de } from 'date-fns/locale';
@ -263,7 +263,7 @@
}
const draft = eventsStore.draftEvent;
if (draft) {
return typeof draft.startTime === 'string' ? parseISO(draft.startTime) : draft.startTime;
return toDate(draft.startTime);
}
return startTime || new Date();
});
@ -274,7 +274,7 @@
}
const draft = eventsStore.draftEvent;
if (draft) {
return typeof draft.endTime === 'string' ? parseISO(draft.endTime) : draft.endTime;
return toDate(draft.endTime);
}
return addMinutes(startTime || new Date(), settingsStore.defaultEventDuration);
});
@ -601,10 +601,6 @@
async function handleDelete() {
if (!event) return;
if (!confirm('Möchten Sie diesen Termin wirklich löschen?')) {
return;
}
submitting = true;
try {
const result = await eventsStore.deleteEvent(event.id);
@ -648,22 +644,25 @@
<span class="header-title">{isEditMode ? 'Termin bearbeiten' : 'Neuer Termin'}</span>
<div class="header-actions">
{#if isEditMode}
<button
type="button"
class="delete-btn"
onclick={handleDelete}
disabled={submitting}
aria-label="Löschen"
<ConfirmationPopover
onConfirm={handleDelete}
variant="danger"
title="Termin löschen?"
confirmLabel="Löschen"
loading={submitting}
placement="bottom"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
<button type="button" class="delete-btn" disabled={submitting} aria-label="Löschen">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</ConfirmationPopover>
{/if}
<button type="button" class="close-btn" onclick={onClose} aria-label="Schließen">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View file

@ -45,6 +45,9 @@ export { ContactAvatar, ContactBadge, ContactSelector } from './molecules';
// Layout
export { ModalFooter, DataCard, PageHeader, KeyboardShortcutsPanel } from './molecules';
// Confirmation (inline popover)
export { ConfirmationPopover } from './molecules';
// Organisms
export { Modal, ConfirmationModal, FormModal, AppSlider } from './organisms';
export type { AppItem } from './organisms';

View file

@ -0,0 +1,407 @@
<script lang="ts">
/**
* ConfirmationPopover - Inline confirmation dialog
*
* A wrapper component that shows a confirmation popover directly at the
* trigger element position, minimizing mouse travel for quick confirmations.
* Uses a portal to escape parent overflow constraints.
*
* @example Delete confirmation
* ```svelte
* <ConfirmationPopover
* onConfirm={handleDelete}
* variant="danger"
* title="Löschen?"
* confirmLabel="Löschen"
* >
* <button class="delete-btn">🗑️</button>
* </ConfirmationPopover>
* ```
*/
import type { Snippet } from 'svelte';
import { Trash, Warning, Check, X } from '@manacore/shared-icons';
type ConfirmationVariant = 'danger' | 'warning' | 'info';
type Placement = 'top' | 'bottom' | 'left' | 'right';
interface Props {
/** Trigger element (usually a button) */
children: Snippet;
/** Called when user confirms the action */
onConfirm: () => void | Promise<void>;
/** Visual variant */
variant?: ConfirmationVariant;
/** Popover title */
title?: string;
/** Optional message */
message?: string;
/** Confirm button label */
confirmLabel?: string;
/** Cancel button label */
cancelLabel?: string;
/** Whether confirm action is in progress */
loading?: boolean;
/** Preferred placement */
placement?: Placement;
/** Disabled state - prevents popover from opening */
disabled?: boolean;
}
let {
children,
onConfirm,
variant = 'danger',
title = 'Bestätigen?',
message,
confirmLabel = 'Bestätigen',
cancelLabel = 'Abbrechen',
loading = false,
placement = 'bottom',
disabled = false,
}: Props = $props();
let visible = $state(false);
let triggerRef = $state<HTMLDivElement | null>(null);
let popoverRef = $state<HTMLDivElement | null>(null);
let confirmBtnRef = $state<HTMLButtonElement | null>(null);
let popoverPosition = $state({ top: 0, left: 0 });
// Portal action - moves element to body to escape overflow constraints
function portal(node: HTMLElement) {
document.body.appendChild(node);
return {
destroy() {
node.remove();
},
};
}
const variantConfig: Record<
ConfirmationVariant,
{
iconColor: string;
iconBg: string;
buttonColor: string;
buttonHover: string;
borderColor: string;
}
> = {
danger: {
iconColor: 'text-red-500',
iconBg: 'bg-red-500/10',
buttonColor: 'bg-red-500 text-white',
buttonHover: 'hover:bg-red-600',
borderColor: 'border-red-500/20',
},
warning: {
iconColor: 'text-yellow-500',
iconBg: 'bg-yellow-500/10',
buttonColor: 'bg-yellow-500 text-white',
buttonHover: 'hover:bg-yellow-600',
borderColor: 'border-yellow-500/20',
},
info: {
iconColor: 'text-blue-500',
iconBg: 'bg-blue-500/10',
buttonColor: 'bg-blue-500 text-white',
buttonHover: 'hover:bg-blue-600',
borderColor: 'border-blue-500/20',
},
};
const config = $derived(variantConfig[variant]);
function handleTriggerClick(e: MouseEvent) {
if (disabled || loading) return;
e.stopPropagation();
// Get position from the clicked element
const target = e.currentTarget as HTMLElement;
if (target) {
const rect = target.getBoundingClientRect();
calculatePosition(rect);
}
visible = true;
// Focus confirm button after popover appears
requestAnimationFrame(() => {
confirmBtnRef?.focus();
});
}
function calculatePosition(rect: DOMRect) {
if (rect.width === 0 && rect.height === 0) return;
const popoverWidth = 240;
const popoverHeight = 120;
const gap = 8;
let top = 0;
let left = 0;
switch (placement) {
case 'top':
top = rect.top - popoverHeight - gap;
left = rect.left + rect.width / 2 - popoverWidth / 2;
break;
case 'bottom':
top = rect.bottom + gap;
left = rect.left + rect.width / 2 - popoverWidth / 2;
break;
case 'left':
top = rect.top + rect.height / 2 - popoverHeight / 2;
left = rect.left - popoverWidth - gap;
break;
case 'right':
top = rect.top + rect.height / 2 - popoverHeight / 2;
left = rect.right + gap;
break;
}
// Keep within viewport bounds
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
if (left < 8) left = 8;
if (left + popoverWidth > viewportWidth - 8) left = viewportWidth - popoverWidth - 8;
if (top < 8) top = 8;
if (top + popoverHeight > viewportHeight - 8) {
if (placement === 'bottom') {
top = rect.top - popoverHeight - gap;
}
}
popoverPosition = { top, left };
}
function handleCancel() {
if (loading) return;
visible = false;
}
async function handleConfirm() {
try {
await onConfirm();
visible = false;
} catch {
// Keep popover open on error
}
}
function handleKeydown(e: KeyboardEvent) {
if (!visible) return;
if (e.key === 'Escape') {
e.preventDefault();
handleCancel();
} else if (e.key === 'Enter' && !loading) {
e.preventDefault();
handleConfirm();
}
}
function handleClickOutside(e: MouseEvent) {
if (!visible || loading) return;
const target = e.target as Node;
// Check if click is inside trigger or popover
if (triggerRef?.contains(target)) return;
if (popoverRef?.contains(target)) return;
visible = false;
}
</script>
<svelte:window onkeydown={handleKeydown} onclick={handleClickOutside} />
<!-- Trigger wrapper -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="confirmation-popover-trigger" bind:this={triggerRef} onclick={handleTriggerClick}>
{@render children()}
</div>
<!-- Portal: Popover rendered to body to escape overflow constraints -->
{#if visible}
<div
use:portal
class="confirmation-popover {config.borderColor}"
bind:this={popoverRef}
style="position: fixed; top: {popoverPosition.top}px; left: {popoverPosition.left}px; z-index: 999999;"
role="dialog"
aria-modal="true"
aria-label={title}
>
<!-- Content -->
<div class="popover-content">
<div class="popover-header">
<div class="popover-icon {config.iconBg} {config.iconColor}">
{#if variant === 'danger'}
<Trash size={16} weight="bold" />
{:else if variant === 'warning'}
<Warning size={16} weight="bold" />
{:else}
<Check size={16} weight="bold" />
{/if}
</div>
<span class="popover-title">{title}</span>
</div>
{#if message}
<p class="popover-message">{message}</p>
{/if}
<div class="popover-actions">
<button type="button" class="btn-cancel" onclick={handleCancel} disabled={loading}>
<X size={14} weight="bold" />
{cancelLabel}
</button>
<button
type="button"
class="btn-confirm {config.buttonColor} {config.buttonHover}"
onclick={handleConfirm}
disabled={loading}
bind:this={confirmBtnRef}
>
{#if loading}
<svg class="spinner" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{:else if variant === 'danger'}
<Trash size={14} weight="bold" />
{:else}
<Check size={14} weight="bold" />
{/if}
{confirmLabel}
</button>
</div>
</div>
</div>
{/if}
<style>
.confirmation-popover-trigger {
display: inline-flex;
}
.confirmation-popover {
min-width: 220px;
max-width: 280px;
background: var(--color-surface-elevated-3);
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-lg, 12px);
box-shadow:
0 10px 25px -5px rgb(0 0 0 / 0.15),
0 8px 10px -6px rgb(0 0 0 / 0.1);
animation: popoverIn 150ms ease-out;
}
@keyframes popoverIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.popover-content {
padding: 12px;
}
.popover-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.popover-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-md, 8px);
flex-shrink: 0;
}
.popover-title {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.popover-message {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
margin: 0 0 12px;
line-height: 1.4;
}
.popover-actions {
display: flex;
gap: 8px;
}
.btn-cancel,
.btn-confirm {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 12px;
font-size: 0.8125rem;
font-weight: 500;
border-radius: var(--radius-md, 8px);
border: none;
cursor: pointer;
transition: all 150ms;
}
.btn-cancel {
background: hsl(var(--color-muted) / 0.5);
color: hsl(var(--color-foreground));
}
.btn-cancel:hover:not(:disabled) {
background: hsl(var(--color-muted));
}
.btn-cancel:disabled,
.btn-confirm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spinner {
width: 14px;
height: 14px;
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -47,3 +47,6 @@ export { default as ModalFooter } from './ModalFooter.svelte';
export { default as DataCard } from './DataCard.svelte';
export { default as PageHeader } from './PageHeader.svelte';
export { default as KeyboardShortcutsPanel } from './KeyboardShortcutsPanel.svelte';
// Confirmation
export { default as ConfirmationPopover } from './ConfirmationPopover.svelte';