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.
225 lines
6.3 KiB
Svelte
225 lines
6.3 KiB
Svelte
<script lang="ts">
|
|
import { getMdiPath, getAllMdiNames } from '$lib/mdi-lookup.svelte';
|
|
import { portal } from '$lib/portal';
|
|
|
|
let { value = '', onselect } = $props<{
|
|
value: string;
|
|
onselect: (icon: string) => void;
|
|
}>();
|
|
|
|
let open = $state(false);
|
|
let search = $state('');
|
|
let buttonEl: HTMLButtonElement;
|
|
let dropdownStyle = $state('');
|
|
|
|
const popular = [
|
|
'mdiServer', 'mdiCamera', 'mdiImage', 'mdiVideo', 'mdiBell', 'mdiSend',
|
|
'mdiRobot', 'mdiHome', 'mdiStar', 'mdiHeart', 'mdiAccount', 'mdiFolder',
|
|
'mdiFolderImage', 'mdiAlbum', 'mdiImageMultiple', 'mdiCloudUpload',
|
|
'mdiEye', 'mdiCog', 'mdiTelegram', 'mdiWebhook', 'mdiMessageText',
|
|
'mdiCalendar', 'mdiClock', 'mdiMapMarker', 'mdiTag', 'mdiFilter',
|
|
'mdiSort', 'mdiMagnify', 'mdiPencil', 'mdiDelete', 'mdiPlus',
|
|
'mdiCheck', 'mdiClose', 'mdiAlert', 'mdiInformation', 'mdiShield',
|
|
'mdiLink', 'mdiDownload', 'mdiUpload', 'mdiRefresh', 'mdiPlay',
|
|
'mdiPause', 'mdiStop', 'mdiSkipNext', 'mdiMusic', 'mdiMovie',
|
|
'mdiFileDocument', 'mdiEmail', 'mdiPhone', 'mdiChat', 'mdiShare',
|
|
];
|
|
|
|
const filtered = $derived.by(() => {
|
|
const allIcons = getAllMdiNames();
|
|
if (!search) return popular.filter(p => allIcons.includes(p));
|
|
const q = search.toLowerCase();
|
|
return allIcons.filter(k => k.toLowerCase().includes(q)).slice(0, 60);
|
|
});
|
|
|
|
function toggleOpen() {
|
|
if (!open && buttonEl) {
|
|
const rect = buttonEl.getBoundingClientRect();
|
|
const popupWidth = 320; // 20rem
|
|
const popupHeight = 320;
|
|
const spaceBelow = window.innerHeight - rect.bottom;
|
|
const top = spaceBelow > popupHeight + 16
|
|
? rect.bottom + 4
|
|
: Math.max(8, rect.top - popupHeight - 4);
|
|
const left = Math.min(rect.left, window.innerWidth - popupWidth - 16);
|
|
dropdownStyle = `position:fixed; z-index:9999; top:${top}px; left:${Math.max(8, left)}px;`;
|
|
}
|
|
open = !open;
|
|
if (!open) search = '';
|
|
}
|
|
|
|
function select(iconName: string) {
|
|
onselect(iconName);
|
|
open = false;
|
|
search = '';
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape' && open) {
|
|
open = false;
|
|
search = '';
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onkeydown={open ? handleKeydown : undefined} />
|
|
|
|
<div class="inline-block">
|
|
<button type="button" bind:this={buttonEl} onclick={toggleOpen}
|
|
class="icon-picker-trigger">
|
|
{#if value && getMdiPath(value)}
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d={getMdiPath(value)} /></svg>
|
|
{:else}
|
|
<span class="icon-picker-placeholder">Icon</span>
|
|
{/if}
|
|
<span class="icon-picker-caret">▾</span>
|
|
</button>
|
|
</div>
|
|
|
|
{#if open}
|
|
<!-- Portal popup so it escapes any backdrop-filter / transform ancestor
|
|
that would otherwise act as the containing block for position:fixed. -->
|
|
<div use:portal class="ip-portal-root">
|
|
<div class="ip-backdrop"
|
|
role="presentation"
|
|
onclick={() => { open = false; search = ''; }}></div>
|
|
|
|
<div style={dropdownStyle} class="ip-popup">
|
|
<input type="text" bind:value={search} placeholder="Search icons..."
|
|
class="ip-search" autocomplete="off" />
|
|
<div class="ip-grid">
|
|
<button type="button" onclick={() => select('')}
|
|
class="ip-cell ip-cell--clear"
|
|
title="No icon">✕</button>
|
|
{#each filtered as iconName}
|
|
<button type="button" onclick={() => select(iconName)}
|
|
class="ip-cell {value === iconName ? 'is-active' : ''}"
|
|
title={iconName.replace('mdi', '')}>
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d={getMdiPath(iconName)} /></svg>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.icon-picker-trigger {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
padding: 0.45rem 0.7rem;
|
|
border-radius: 0.625rem;
|
|
border: 1px solid var(--color-border);
|
|
background: var(--color-input-bg);
|
|
color: var(--color-foreground);
|
|
font-size: 0.85rem;
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
.icon-picker-trigger:hover {
|
|
background: var(--color-glass-strong);
|
|
border-color: var(--color-rule-strong);
|
|
}
|
|
.icon-picker-placeholder {
|
|
color: var(--color-muted-foreground);
|
|
font-size: 0.78rem;
|
|
}
|
|
.icon-picker-caret {
|
|
color: var(--color-muted-foreground);
|
|
font-size: 0.7rem;
|
|
}
|
|
|
|
/* Portal root — drains the popup out of any backdrop-filter ancestor */
|
|
.ip-portal-root {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 9998;
|
|
pointer-events: none;
|
|
}
|
|
.ip-backdrop {
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: auto;
|
|
}
|
|
.ip-popup {
|
|
pointer-events: auto;
|
|
width: 20rem;
|
|
--ip-solid-bg: #131520;
|
|
background: var(--ip-solid-bg);
|
|
border: 1px solid var(--color-rule-strong);
|
|
border-radius: 14px;
|
|
box-shadow: var(--shadow-card), 0 24px 48px -16px rgba(0, 0, 0, 0.55);
|
|
padding: 0.65rem;
|
|
position: relative;
|
|
}
|
|
:global([data-theme="light"]) .ip-popup { --ip-solid-bg: #fafafe; }
|
|
.ip-popup::after {
|
|
content: '';
|
|
position: absolute; inset: 0;
|
|
border-radius: inherit;
|
|
pointer-events: none;
|
|
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
|
|
opacity: 0.4;
|
|
}
|
|
.ip-search {
|
|
width: 100%;
|
|
padding: 0.45rem 0.6rem;
|
|
margin-bottom: 0.5rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 8px;
|
|
background: var(--color-glass-strong);
|
|
color: var(--color-foreground);
|
|
font-size: 0.82rem;
|
|
outline: none;
|
|
font-family: inherit;
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
.ip-search:focus {
|
|
border-color: var(--color-primary);
|
|
box-shadow: 0 0 0 2px var(--color-glow);
|
|
}
|
|
.ip-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(8, 1fr);
|
|
gap: 0.25rem;
|
|
max-height: 14rem;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
overscroll-behavior: contain;
|
|
scrollbar-width: thin;
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
.ip-cell {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
aspect-ratio: 1;
|
|
border-radius: 8px;
|
|
border: 1px solid transparent;
|
|
background: transparent;
|
|
color: var(--color-foreground);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
.ip-cell:hover {
|
|
background: var(--color-glass-strong);
|
|
border-color: var(--color-border);
|
|
}
|
|
.ip-cell.is-active {
|
|
background: linear-gradient(135deg,
|
|
color-mix(in srgb, var(--color-primary) 18%, transparent),
|
|
color-mix(in srgb, var(--color-orchid) 18%, transparent));
|
|
border-color: var(--color-primary);
|
|
color: var(--color-primary);
|
|
box-shadow: inset 0 1px 0 var(--color-highlight);
|
|
}
|
|
.ip-cell--clear {
|
|
color: var(--color-muted-foreground);
|
|
font-size: 0.75rem;
|
|
}
|
|
</style>
|