Files
Oxicloud/frontend/src/lib/components/Modal.svelte
T

204 lines
4.1 KiB
Svelte
Raw Normal View History

2026-06-17 17:06:30 -06:00
<script lang="ts">
import type { Snippet } from 'svelte';
2026-06-17 22:52:06 -06:00
import { t } from '$lib/i18n/index.svelte';
2026-06-17 17:06:30 -06:00
interface Props {
open: boolean;
title?: string;
/** Called when the user requests close (backdrop click, Escape, ✕). */
onclose?: () => void;
children?: Snippet;
footer?: Snippet;
}
let { open = $bindable(false), title, onclose, children, footer }: Props = $props();
2026-06-17 22:07:18 -06:00
let dialogEl = $state<HTMLElement | null>(null);
let prevFocus: HTMLElement | null = null;
2026-06-17 17:06:30 -06:00
function close() {
open = false;
onclose?.();
}
2026-06-17 22:07:18 -06:00
const FOCUSABLE =
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
function focusables(): HTMLElement[] {
if (!dialogEl) return [];
return Array.from(dialogEl.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => el.offsetParent !== null || el === document.activeElement
);
}
2026-06-17 17:06:30 -06:00
function onkeydown(e: KeyboardEvent) {
2026-06-17 22:07:18 -06:00
if (e.key === 'Escape') {
close();
return;
}
// Focus trap: keep Tab cycling inside the dialog.
if (e.key === 'Tab') {
const items = focusables();
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
2026-06-17 17:06:30 -06:00
}
2026-06-17 22:07:18 -06:00
// On open: remember the previously focused element and move focus into the
// dialog. On close: restore focus so keyboard users aren't dumped at <body>.
$effect(() => {
if (open) {
prevFocus = (document.activeElement as HTMLElement | null) ?? null;
requestAnimationFrame(() => {
const items = focusables();
(items[0] ?? dialogEl)?.focus();
});
} else if (prevFocus) {
prevFocus.focus();
prevFocus = null;
}
});
2026-06-17 17:06:30 -06:00
</script>
<svelte:window onkeydown={open ? onkeydown : undefined} />
{#if open}
<!-- backdrop -->
<div
class="modal__backdrop"
role="presentation"
onclick={(e) => {
if (e.target === e.currentTarget) close();
}}
>
2026-06-17 22:07:18 -06:00
<div
class="modal"
role="dialog"
aria-modal="true"
aria-label={title}
tabindex="-1"
data-testid="modal"
2026-06-17 22:07:18 -06:00
bind:this={dialogEl}
>
2026-06-17 17:06:30 -06:00
{#if title}
<header class="modal__header">
<h2 class="modal__title">{title}</h2>
<button
class="modal__close"
aria-label={t('common.close', 'Close')}
data-testid="modal-close-btn"
onclick={close}>×</button
2026-06-17 22:52:06 -06:00
>
2026-06-17 17:06:30 -06:00
</header>
{/if}
<div class="modal__body">
{@render children?.()}
</div>
{#if footer}
<footer class="modal__footer">
{@render footer()}
</footer>
{/if}
</div>
</div>
{/if}
<style>
.modal__backdrop {
position: fixed;
inset: 0;
background: var(--color-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 900;
padding: 1rem;
2026-06-17 22:07:18 -06:00
animation: modal-fade 0.16s ease;
2026-06-17 17:06:30 -06:00
}
.modal {
background: var(--color-bg-surface);
color: var(--color-text);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
width: min(92vw, 32rem);
max-height: 90vh;
overflow: auto;
display: flex;
flex-direction: column;
2026-06-17 22:07:18 -06:00
animation: modal-pop 0.18s ease;
2026-06-17 17:06:30 -06:00
}
.modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-border);
}
.modal__title {
margin: 0;
font-size: 1.125rem;
}
.modal__close {
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--color-text-muted);
}
.modal__body {
padding: 1.25rem;
}
.modal__footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--color-border);
}
2026-06-17 22:07:18 -06:00
@keyframes modal-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-pop {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.modal__backdrop,
.modal {
animation: none;
}
}
2026-06-17 17:06:30 -06:00
</style>