9afd38e50e
- Add overscroll-behavior: contain to all in-modal/popup scroll containers (Modal body, EntitySelect, MultiEntitySelect, IconPicker, IconGridSelect, SearchPalette, TimezoneSelector) so reaching the inner scroll boundary no longer scrolls the page underneath. - Telegram bot Discover Chats no longer collapses the existing chat list into a "Loading…" placeholder. Split chatsLoading (initial) from chatsRefreshing (Discover); rows are keyed by chat.id with flip+fade animations; the list dims with a sweeping shimmer bar while the Discover button shows a spinning icon and "Discovering chats…" label. Honors prefers-reduced-motion.
218 lines
5.1 KiB
Svelte
218 lines
5.1 KiB
Svelte
<script lang="ts">
|
|
import MdiIcon from './MdiIcon.svelte';
|
|
import { t } from '$lib/i18n';
|
|
import { portal } from '$lib/portal';
|
|
|
|
let { open = false, title = '', onclose, children } = $props<{
|
|
open: boolean;
|
|
title?: string;
|
|
onclose: () => void;
|
|
children: import('svelte').Snippet;
|
|
}>();
|
|
|
|
let visible = $state(false);
|
|
let panelEl = $state<HTMLDivElement | undefined>();
|
|
let previouslyFocused: HTMLElement | null = null;
|
|
|
|
const uniqueId = `modal-${Math.random().toString(36).slice(2, 9)}`;
|
|
|
|
$effect(() => {
|
|
if (open) {
|
|
previouslyFocused = document.activeElement as HTMLElement | null;
|
|
requestAnimationFrame(() => {
|
|
visible = true;
|
|
// Focus first focusable element inside the modal
|
|
requestAnimationFrame(() => {
|
|
const focusable = panelEl?.querySelector<HTMLElement>(
|
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
);
|
|
focusable?.focus();
|
|
});
|
|
});
|
|
} else {
|
|
visible = false;
|
|
// Restore focus to the previously focused element
|
|
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
|
|
previouslyFocused.focus();
|
|
previouslyFocused = null;
|
|
}
|
|
}
|
|
});
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') {
|
|
onclose();
|
|
return;
|
|
}
|
|
// Focus trap: Tab / Shift+Tab
|
|
if (e.key === 'Tab' && panelEl) {
|
|
const focusableElements = panelEl.querySelectorAll<HTMLElement>(
|
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
);
|
|
if (focusableElements.length === 0) return;
|
|
const first = focusableElements[0];
|
|
const last = focusableElements[focusableElements.length - 1];
|
|
if (e.shiftKey) {
|
|
if (document.activeElement === first) {
|
|
e.preventDefault();
|
|
last.focus();
|
|
}
|
|
} else {
|
|
if (document.activeElement === last) {
|
|
e.preventDefault();
|
|
first.focus();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleBackdropKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') onclose();
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onkeydown={open ? handleKeydown : undefined} />
|
|
|
|
{#if open}
|
|
<div use:portal class="modal-portal-root">
|
|
<div
|
|
class="modal-backdrop"
|
|
class:visible
|
|
onclick={onclose}
|
|
onkeydown={handleBackdropKeydown}
|
|
role="button"
|
|
tabindex="-1"
|
|
aria-label={t('common.close')}
|
|
>
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<div
|
|
bind:this={panelEl}
|
|
class="modal-panel"
|
|
class:visible
|
|
role="dialog"
|
|
tabindex="-1"
|
|
aria-modal="true"
|
|
aria-labelledby="modal-title-{uniqueId}"
|
|
onclick={(e) => e.stopPropagation()}
|
|
>
|
|
<div class="modal-head">
|
|
<h3 id="modal-title-{uniqueId}" class="modal-title">{title}</h3>
|
|
<button class="modal-close" onclick={onclose} aria-label={t('common.close')}>
|
|
<MdiIcon name="mdiClose" size={18} />
|
|
</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
{@render children()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.modal-portal-root {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 9999;
|
|
}
|
|
|
|
.modal-backdrop {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(0, 0, 0, 0);
|
|
backdrop-filter: blur(0px);
|
|
transition: background 0.25s ease, backdrop-filter 0.25s ease;
|
|
}
|
|
|
|
.modal-backdrop.visible {
|
|
background: rgba(0, 0, 0, 0.55);
|
|
backdrop-filter: blur(8px) saturate(120%);
|
|
-webkit-backdrop-filter: blur(8px) saturate(120%);
|
|
}
|
|
|
|
.modal-panel {
|
|
--modal-solid-bg: #131520;
|
|
background: var(--modal-solid-bg);
|
|
border: 1px solid var(--color-rule-strong);
|
|
border-radius: 18px;
|
|
width: 100%;
|
|
max-width: 32rem;
|
|
max-height: 80vh;
|
|
margin: 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
opacity: 0;
|
|
transform: translateY(12px) scale(0.97);
|
|
transition: opacity 0.25s ease, transform 0.25s ease;
|
|
box-shadow:
|
|
var(--shadow-card),
|
|
0 30px 80px -20px rgba(0, 0, 0, 0.6);
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
.modal-panel::after {
|
|
content: '';
|
|
position: absolute; inset: 0;
|
|
border-radius: inherit;
|
|
pointer-events: none;
|
|
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
|
|
opacity: 0.4;
|
|
}
|
|
|
|
:global([data-theme="light"]) .modal-panel { --modal-solid-bg: #fafafe; }
|
|
|
|
.modal-panel.visible {
|
|
opacity: 1;
|
|
transform: translateY(0) scale(1);
|
|
}
|
|
|
|
.modal-head {
|
|
position: relative;
|
|
z-index: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 1.4rem 1.5rem 1rem;
|
|
}
|
|
|
|
.modal-title {
|
|
font-family: var(--font-display);
|
|
font-weight: 400;
|
|
font-size: 1.4rem;
|
|
letter-spacing: -0.02em;
|
|
color: var(--color-foreground);
|
|
margin: 0;
|
|
}
|
|
|
|
.modal-body {
|
|
position: relative;
|
|
z-index: 1;
|
|
padding: 0 1.5rem 1.5rem;
|
|
overflow-y: auto;
|
|
overscroll-behavior: contain;
|
|
}
|
|
|
|
.modal-close {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 2.5rem;
|
|
height: 2.5rem;
|
|
border-radius: 10px;
|
|
border: 1px solid transparent;
|
|
background: transparent;
|
|
color: var(--color-muted-foreground);
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.modal-close:hover {
|
|
background: var(--color-glass-strong);
|
|
border-color: var(--color-border);
|
|
color: var(--color-foreground);
|
|
}
|
|
</style>
|