fix(redesign): contain modal scroll chaining and smooth Telegram chat refresh

- 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.
This commit is contained in:
2026-04-28 18:52:20 +03:00
parent aa9548d884
commit 9afd38e50e
10 changed files with 154 additions and 56 deletions
@@ -304,6 +304,7 @@
/* List */
.ep-list {
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
padding: 0.35rem;
position: relative;
@@ -195,6 +195,7 @@
padding: 0.5rem;
max-height: 320px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
:global([data-theme="light"]) .icon-grid-popup { --igs-solid-bg: #fafafe; }
@@ -188,6 +188,7 @@
max-height: 14rem;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
scrollbar-width: thin;
position: relative;
z-index: 1;
+1
View File
@@ -192,6 +192,7 @@
z-index: 1;
padding: 0 1.5rem 1.5rem;
overflow-y: auto;
overscroll-behavior: contain;
}
.modal-close {
@@ -307,6 +307,7 @@
.mes-list {
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
padding: 0.25rem 0;
}
@@ -342,6 +342,7 @@
.sp-results {
max-height: 52vh;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
padding: 0.35rem;
position: relative;
@@ -530,6 +530,7 @@
.tz-list {
overflow-y: auto;
overscroll-behavior: contain;
/* No top padding — the sticky group head is at top:0 of the
scroll container, so any padding-top would let scrolling
items leak into the gap above the sticky header. */
+1
View File
@@ -475,6 +475,7 @@
"noCommandsForProvider": "This provider type does not support bot commands.",
"syncCommands": "Sync Commands",
"discoverChats": "Discover chats from Telegram",
"discoveringChats": "Discovering chats…",
"clickToCopy": "Click to copy chat ID",
"chatsDiscovered": "Chats discovered",
"chatDeleted": "Chat removed",
+1
View File
@@ -475,6 +475,7 @@
"noCommandsForProvider": "Этот тип провайдера не поддерживает команды бота.",
"syncCommands": "Синхр. команды",
"discoverChats": "Обнаружить чаты из Telegram",
"discoveringChats": "Поиск чатов…",
"clickToCopy": "Нажмите, чтобы скопировать ID чата",
"chatsDiscovered": "Чаты обнаружены",
"chatDeleted": "Чат удалён",
+98 -9
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { slide, fade } from 'svelte/transition';
import { flip } from 'svelte/animate';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
@@ -35,6 +36,10 @@
// Per-bot expandable sections
let chats = $state<Record<number, TelegramChat[]>>({});
let chatsLoading = $state<Record<number, boolean>>({});
// Distinct from chatsLoading: refresh keeps the existing list visible
// instead of swapping it for a placeholder, avoiding the disorienting
// "everything disappears" flash during Discover.
let chatsRefreshing = $state<Record<number, boolean>>({});
let expandedSection = $state<Record<number, string>>({});
// Webhook status per bot
@@ -98,12 +103,13 @@
}
async function discoverChats(botId: number) {
chatsLoading = { ...chatsLoading, [botId]: true };
if (chatsRefreshing[botId]) return;
chatsRefreshing = { ...chatsRefreshing, [botId]: true };
try {
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
snackSuccess(t('telegramBot.chatsDiscovered'));
} catch (err: any) { snackError(err.message); }
chatsLoading = { ...chatsLoading, [botId]: false };
chatsRefreshing = { ...chatsRefreshing, [botId]: false };
}
async function deleteChat(botId: number, chatDbId: number) {
@@ -371,12 +377,16 @@
<!-- Chats section -->
{#if expandedSection[bot.id] === 'chats'}
<div class="mt-3 border-t border-[var(--color-border)] pt-3" in:slide>
{#if chatsLoading[bot.id]}
{#if chatsLoading[bot.id] && !chats[bot.id]}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
{:else if (chats[bot.id] || []).length === 0}
{:else if (chats[bot.id] || []).length === 0 && !chatsRefreshing[bot.id]}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
{:else}
{@const gridStyle = "display:grid; grid-template-columns:1fr 80px 40px 100px 50px 130px 60px; align-items:center; gap:0.5rem;"}
<div class="chat-list-wrap" class:is-refreshing={chatsRefreshing[bot.id]}>
{#if chatsRefreshing[bot.id]}
<div class="chat-shimmer" aria-hidden="true" transition:fade={{ duration: 180 }}></div>
{/if}
<!-- Header -->
<div style="{gridStyle} padding:0.25rem 0.5rem; border-bottom:1px solid var(--color-border);"
class="text-[0.65rem] font-semibold uppercase tracking-wide text-[var(--color-muted-foreground)]">
@@ -389,9 +399,12 @@
<span></span>
</div>
<!-- Rows -->
{#each chats[bot.id] as chat}
{#each (chats[bot.id] || []) as chat (chat.id)}
<div style={gridStyle}
class="text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
class="chat-row text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
animate:flip={{ duration: 280 }}
in:fade={{ duration: 220, delay: 60 }}
out:fade={{ duration: 140 }}
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); copyChatId(e as unknown as MouseEvent, chat.chat_id); } }}
title={t('telegramBot.clickToCopy')}
@@ -426,11 +439,18 @@
</div>
</div>
{/each}
{#if chatsRefreshing[bot.id] && (chats[bot.id] || []).length === 0}
<p class="text-xs text-[var(--color-muted-foreground)] py-2 px-2">{t('telegramBot.discoveringChats')}</p>
{/if}
</div>
{/if}
<button onclick={() => discoverChats(bot.id)}
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
disabled={chatsRefreshing[bot.id]}
class="discover-btn text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1 disabled:opacity-70 disabled:cursor-default disabled:no-underline">
<span class="discover-icon" class:is-spinning={chatsRefreshing[bot.id]}>
<MdiIcon name="mdiSync" size={14} />
{t('telegramBot.discoverChats')}
</span>
{chatsRefreshing[bot.id] ? t('telegramBot.discoveringChats') : t('telegramBot.discoverChats')}
</button>
</div>
{/if}
@@ -553,3 +573,72 @@
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<style>
/* Chat list — smooth refresh state.
The list stays mounted during Discover; we only dim it slightly
and run a thin shimmer bar across the top so the user sees
"refreshing" instead of "everything vanished and came back". */
.chat-list-wrap {
position: relative;
transition: opacity 0.25s ease, filter 0.25s ease;
}
.chat-list-wrap.is-refreshing {
opacity: 0.78;
filter: saturate(0.9);
}
.chat-list-wrap.is-refreshing .chat-row {
pointer-events: none;
}
.chat-shimmer {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
overflow: hidden;
border-radius: 2px;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
z-index: 2;
}
.chat-shimmer::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--color-primary) 70%, transparent) 50%,
transparent 100%
);
transform: translateX(-100%);
animation: chat-shimmer-sweep 1.15s ease-in-out infinite;
}
@keyframes chat-shimmer-sweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.discover-icon {
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease;
}
.discover-icon.is-spinning {
animation: discover-spin 1s linear infinite;
}
@keyframes discover-spin {
to { transform: rotate(-360deg); }
}
@media (prefers-reduced-motion: reduce) {
.chat-shimmer::after,
.discover-icon.is-spinning {
animation: none;
}
.chat-list-wrap {
transition: none;
}
}
</style>