managarten/packages/shared-ui/src/organisms/Modal.svelte
Till JS c27f6f88f0 feat(shared-ui): add focusTrap action and apply to shared Modal
Create reusable Svelte action for focus trapping in modals/dialogs.
Traps Tab/Shift+Tab within element and restores focus on destroy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:04:04 +01:00

111 lines
2.6 KiB
Svelte

<script lang="ts">
import type { Snippet } from 'svelte';
import { X } from '@manacore/shared-icons';
import Text from '../atoms/Text.svelte';
import { focusTrap } from '../actions/focusTrap';
interface Props {
visible: boolean;
onClose: () => void;
title?: string;
icon?: Snippet;
children: Snippet;
footer?: Snippet;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl';
showHeader?: boolean;
}
let {
visible,
onClose,
title,
icon,
children,
footer,
maxWidth = 'lg',
showHeader = true,
}: Props = $props();
const maxWidthClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
};
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && visible) {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if visible}
<!-- Modal Backdrop -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="fixed inset-0 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
style="z-index: 9990;"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Enter' && handleBackdropClick(e as unknown as MouseEvent)}
role="dialog"
aria-modal="true"
tabindex="-1"
use:focusTrap
>
<!-- Modal Content -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative flex max-h-[90vh] w-full {maxWidthClasses[
maxWidth
]} flex-col rounded-2xl border border-border bg-surface-elevated-2 backdrop-blur-xl shadow-2xl"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
{#if showHeader}
<!-- Header -->
<div class="flex items-center justify-between p-6 border-b border-border">
<div class="flex items-center gap-3 flex-1">
{#if icon}
{@render icon()}
{/if}
{#if title}
<Text variant="large" weight="semibold">
{title}
</Text>
{/if}
</div>
<button
onclick={onClose}
class="p-2 rounded-xl bg-foreground/5 hover:bg-foreground/10 transition-all duration-200 hover:scale-105"
aria-label="Close"
>
<X size={18} weight="bold" class="text-muted-foreground" />
</button>
</div>
{/if}
<!-- Body (scrollable) -->
<div class="flex-1 overflow-y-auto p-6">
{@render children()}
</div>
<!-- Footer (optional) -->
{#if footer}
<div class="border-t border-border p-6">
{@render footer()}
</div>
{/if}
</div>
</div>
{/if}