mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
cab1905a2c
commit
5190b1449a
4 changed files with 434 additions and 22 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
407
packages/shared-ui/src/molecules/ConfirmationPopover.svelte
Normal file
407
packages/shared-ui/src/molecules/ConfirmationPopover.svelte
Normal 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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue