mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
🚸 ux(matrix-web): improve mobile responsiveness
- Add slide-in sidebar overlay with backdrop on mobile - Make message actions appear below message on mobile - Adjust emoji picker positioning for viewport awareness - Reduce excessive padding on mobile screens - Hide disabled call buttons on small screens - Add responsive widths to panels and dialogs - Close sidebar automatically when selecting room on mobile
This commit is contained in:
parent
6f1b2654f1
commit
f2cd8621cb
19 changed files with 1231 additions and 85 deletions
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import ContactCardSkeleton from './ContactCardSkeleton.svelte';
|
||||
import { calculateFadeOpacity } from './utils';
|
||||
import { calculateFadeOpacity } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
/** Number of skeleton cards to show */
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import ContactRowSkeleton from './ContactRowSkeleton.svelte';
|
||||
import { calculateFadeOpacity } from './utils';
|
||||
import { calculateFadeOpacity } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
/** Number of skeleton rows to show */
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@
|
|||
* Shows stats cards and duplicate groups with fade effect
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
import { SkeletonBox, calculateFadeOpacity } from '@manacore/shared-ui';
|
||||
import DuplicateGroupSkeleton from './DuplicateGroupSkeleton.svelte';
|
||||
import { calculateFadeOpacity } from './utils';
|
||||
|
||||
interface Props {
|
||||
/** Number of duplicate groups to show */
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import TagCardSkeleton from './TagCardSkeleton.svelte';
|
||||
import { calculateFadeOpacity } from './utils';
|
||||
import { calculateFadeOpacity } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
/** Number of skeleton cards to show */
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
* Built on top of @manacore/shared-ui skeleton primitives.
|
||||
*/
|
||||
|
||||
// Utilities
|
||||
export { calculateFadeOpacity } from './utils';
|
||||
// Utilities (re-exported from shared-ui)
|
||||
export { calculateFadeOpacity } from '@manacore/shared-ui';
|
||||
|
||||
// Contact List/Grid Skeletons
|
||||
export { default as ContactRowSkeleton } from './ContactRowSkeleton.svelte';
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
/**
|
||||
* Skeleton utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Calculate opacity for cascading fade effect in skeleton lists
|
||||
* @param index Current item index
|
||||
* @param count Total number of items
|
||||
* @param minOpacity Minimum opacity (default: 0.3)
|
||||
* @returns Opacity value between minOpacity and 1
|
||||
*/
|
||||
export function calculateFadeOpacity(
|
||||
index: number,
|
||||
count: number,
|
||||
minOpacity: number = 0.3
|
||||
): number {
|
||||
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
|
||||
return Math.max(minOpacity, 1 - index * fadeStep);
|
||||
}
|
||||
|
|
@ -103,12 +103,12 @@
|
|||
{#if open}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onclick={handleClose}
|
||||
>
|
||||
<!-- Dialog -->
|
||||
<div
|
||||
class="w-full max-w-md rounded-xl bg-base-100 shadow-xl"
|
||||
class="w-full max-w-md rounded-xl bg-base-100 shadow-xl max-h-[90vh] overflow-y-auto"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
|
|
|||
|
|
@ -74,7 +74,10 @@
|
|||
const codeColor = isOwn ? 'bg-white/20 text-white' : 'bg-black/5 dark:bg-white/10';
|
||||
|
||||
// Inline code (backticks) - process first to avoid conflicts
|
||||
text = text.replace(/`([^`]+)`/g, `<code class="px-1 py-0.5 rounded text-sm font-mono ${codeColor}">$1</code>`);
|
||||
text = text.replace(
|
||||
/`([^`]+)`/g,
|
||||
`<code class="px-1 py-0.5 rounded text-sm font-mono ${codeColor}">$1</code>`
|
||||
);
|
||||
|
||||
// Bold (**text** or __text__)
|
||||
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
|
|
@ -251,7 +254,11 @@
|
|||
{/if}
|
||||
|
||||
<!-- Message Content -->
|
||||
<div class="flex flex-col {message.isOwn ? 'items-end' : 'items-start'} max-w-[75%] relative">
|
||||
<div
|
||||
class="flex flex-col {message.isOwn
|
||||
? 'items-end'
|
||||
: 'items-start'} max-w-[85%] sm:max-w-[75%] relative"
|
||||
>
|
||||
<!-- Sender name (for others only) -->
|
||||
{#if showAvatar && !message.isOwn}
|
||||
<span class="text-xs text-muted-foreground mb-1 px-1">{message.senderName}</span>
|
||||
|
|
@ -433,7 +440,9 @@
|
|||
</p>
|
||||
{:else}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
<p class="whitespace-pre-wrap break-words text-[15px] leading-relaxed">{@html formatMessageBody(message.body, message.isOwn)}</p>
|
||||
<p class="whitespace-pre-wrap break-words text-[15px] leading-relaxed">
|
||||
{@html formatMessageBody(message.body, message.isOwn)}
|
||||
</p>
|
||||
|
||||
<!-- Link Preview Card -->
|
||||
{#if firstUrl()}
|
||||
|
|
@ -451,7 +460,9 @@
|
|||
class="h-5 w-5 rounded-sm"
|
||||
onerror={(e) => ((e.currentTarget as HTMLImageElement).style.display = 'none')}
|
||||
/>
|
||||
<span class="text-xs truncate {message.isOwn ? 'text-white/80' : 'text-muted-foreground'}">
|
||||
<span
|
||||
class="text-xs truncate {message.isOwn ? 'text-white/80' : 'text-muted-foreground'}"
|
||||
>
|
||||
{getDomain(firstUrl() || '')}
|
||||
</span>
|
||||
</a>
|
||||
|
|
@ -489,34 +500,31 @@
|
|||
{/if}
|
||||
|
||||
<!-- Time and read status -->
|
||||
<div
|
||||
class="flex items-center gap-1.5 mt-1.5 px-1 {message.isOwn ? 'justify-end' : ''}"
|
||||
>
|
||||
<span class="text-xs text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity">{formattedTime()}</span>
|
||||
<div class="flex items-center gap-1.5 mt-1.5 px-1 {message.isOwn ? 'justify-end' : ''}">
|
||||
<span
|
||||
class="text-xs text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>{formattedTime()}</span
|
||||
>
|
||||
<!-- Read receipt indicator (for own messages) -->
|
||||
{#if message.isOwn}
|
||||
{#if message.readBy && message.readBy.length > 0}
|
||||
<Checks
|
||||
class="h-4 w-4 text-blue-500"
|
||||
weight="bold"
|
||||
title="Gelesen von: {message.readBy.map(r => r.userName).join(', ')}"
|
||||
title="Gelesen von: {message.readBy.map((r) => r.userName).join(', ')}"
|
||||
/>
|
||||
{:else}
|
||||
<Check
|
||||
class="h-4 w-4 text-muted-foreground/50"
|
||||
weight="bold"
|
||||
title="Gesendet"
|
||||
/>
|
||||
<Check class="h-4 w-4 text-muted-foreground/50" weight="bold" title="Gesendet" />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Message actions (hover) -->
|
||||
<!-- Message actions (hover/tap) -->
|
||||
{#if showActions && !message.redacted}
|
||||
<div
|
||||
class="absolute {message.isOwn
|
||||
? '-left-28'
|
||||
: '-right-28'} top-0 flex items-center gap-1 rounded-xl glass p-1.5 shadow-lg"
|
||||
class="absolute flex items-center gap-1 rounded-xl glass p-1.5 shadow-lg z-20
|
||||
{message.isOwn ? 'right-0 lg:-left-28 lg:right-auto' : 'left-0 lg:-right-28 lg:left-auto'}
|
||||
top-full mt-1 lg:top-0 lg:mt-0"
|
||||
>
|
||||
<!-- Emoji reaction button -->
|
||||
<div class="relative">
|
||||
|
|
@ -536,9 +544,9 @@
|
|||
></button>
|
||||
<!-- Emoji picker dropdown -->
|
||||
<div
|
||||
class="absolute {message.isOwn
|
||||
? 'right-0'
|
||||
: 'left-0'} bottom-full mb-2 z-50 flex gap-1 rounded-xl bg-white dark:bg-zinc-800 border border-black/10 dark:border-white/10 p-2 shadow-xl"
|
||||
class="absolute z-50 flex gap-1 rounded-xl bg-white dark:bg-zinc-800 border border-black/10 dark:border-white/10 p-2 shadow-xl
|
||||
left-0 top-full mt-2 lg:bottom-full lg:top-auto lg:mt-0 lg:mb-2
|
||||
{message.isOwn ? 'lg:right-0 lg:left-auto' : ''}"
|
||||
>
|
||||
{#each quickEmojis as emoji}
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="p-3 pb-20">
|
||||
<div class="p-3 pb-4 lg:pb-20">
|
||||
<!-- Reply/Edit Preview -->
|
||||
{#if replyTo || editMessage}
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -102,14 +102,14 @@
|
|||
<!-- Actions -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="p-2.5 rounded-xl glass-button shadow-sm disabled:opacity-40"
|
||||
class="hidden sm:flex p-2.5 rounded-xl glass-button shadow-sm disabled:opacity-40"
|
||||
title="Sprachanruf"
|
||||
disabled
|
||||
>
|
||||
<Phone class="h-5 w-5 text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
class="p-2.5 rounded-xl glass-button shadow-sm disabled:opacity-40"
|
||||
class="hidden sm:flex p-2.5 rounded-xl glass-button shadow-sm disabled:opacity-40"
|
||||
title="Videoanruf"
|
||||
disabled
|
||||
>
|
||||
|
|
|
|||
|
|
@ -13,9 +13,18 @@
|
|||
|
||||
interface Props {
|
||||
onCreateRoom?: () => void;
|
||||
onSelectRoom?: (roomId: string) => void;
|
||||
}
|
||||
|
||||
let { onCreateRoom }: Props = $props();
|
||||
let { onCreateRoom, onSelectRoom }: Props = $props();
|
||||
|
||||
function handleSelectRoom(roomId: string) {
|
||||
if (onSelectRoom) {
|
||||
onSelectRoom(roomId);
|
||||
} else {
|
||||
matrixStore.selectRoom(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
let search = $state('');
|
||||
|
||||
|
|
@ -143,7 +152,7 @@
|
|||
<RoomItem
|
||||
{room}
|
||||
selected={room.id === matrixStore.currentRoomId}
|
||||
onclick={() => matrixStore.selectRoom(room.id)}
|
||||
onclick={() => handleSelectRoom(room.id)}
|
||||
/>
|
||||
{:else}
|
||||
{#if !search}
|
||||
|
|
@ -171,7 +180,7 @@
|
|||
<RoomItem
|
||||
{room}
|
||||
selected={room.id === matrixStore.currentRoomId}
|
||||
onclick={() => matrixStore.selectRoom(room.id)}
|
||||
onclick={() => handleSelectRoom(room.id)}
|
||||
/>
|
||||
{:else}
|
||||
{#if !search}
|
||||
|
|
@ -191,7 +200,7 @@
|
|||
</div>
|
||||
|
||||
<!-- New Room Button -->
|
||||
<div class="border-t border-black/10 dark:border-white/10 p-3 pb-20">
|
||||
<div class="border-t border-black/10 dark:border-white/10 p-3 pb-4 lg:pb-20">
|
||||
<button
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl
|
||||
bg-gradient-to-r from-violet-500 to-purple-600 text-white font-medium
|
||||
|
|
|
|||
|
|
@ -78,9 +78,15 @@
|
|||
</script>
|
||||
|
||||
{#if open && room}
|
||||
<!-- Backdrop for mobile -->
|
||||
<button
|
||||
class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden"
|
||||
onclick={onClose}
|
||||
aria-label="Schließen"
|
||||
></button>
|
||||
<!-- Slide-in Panel -->
|
||||
<div
|
||||
class="fixed inset-y-0 right-0 z-40 flex w-80 flex-col border-l border-base-300 bg-base-100 shadow-xl"
|
||||
class="fixed inset-y-0 right-0 z-50 flex w-[90vw] max-w-[320px] lg:w-80 flex-col border-l border-base-300 bg-base-100 shadow-xl"
|
||||
>
|
||||
<!-- Header -->
|
||||
<header class="flex items-center justify-between border-b border-base-300 px-4 py-3">
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@
|
|||
import CreateRoomDialog from '$lib/components/chat/CreateRoomDialog.svelte';
|
||||
import RoomSettingsPanel from '$lib/components/chat/RoomSettingsPanel.svelte';
|
||||
import { ChatCircle, Plus } from '@manacore/shared-icons';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
let sidebarOpen = $state(true);
|
||||
// Start with sidebar closed on mobile
|
||||
let sidebarOpen = $state(browser ? window.innerWidth >= 1024 : true);
|
||||
let showCreateRoom = $state(false);
|
||||
let showRoomSettings = $state(false);
|
||||
|
||||
|
|
@ -13,10 +15,31 @@
|
|||
let replyTo = $state<SimpleMessage | null>(null);
|
||||
let editMessage = $state<SimpleMessage | null>(null);
|
||||
|
||||
// Check if mobile
|
||||
let isMobile = $state(browser ? window.innerWidth < 1024 : false);
|
||||
|
||||
// Update on resize
|
||||
if (browser) {
|
||||
window.addEventListener('resize', () => {
|
||||
isMobile = window.innerWidth < 1024;
|
||||
// Auto-close sidebar on resize to mobile
|
||||
if (isMobile && sidebarOpen) {
|
||||
sidebarOpen = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarOpen = !sidebarOpen;
|
||||
}
|
||||
|
||||
function selectRoomAndCloseSidebar(roomId: string) {
|
||||
matrixStore.selectRoom(roomId);
|
||||
if (isMobile) {
|
||||
sidebarOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleReply(message: SimpleMessage) {
|
||||
editMessage = null;
|
||||
replyTo = message;
|
||||
|
|
@ -32,12 +55,22 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="chat-layout flex h-full min-h-0 overflow-hidden bg-background">
|
||||
<div class="chat-layout flex h-full min-h-0 overflow-hidden bg-background relative">
|
||||
<!-- Mobile Sidebar Backdrop -->
|
||||
{#if sidebarOpen && isMobile}
|
||||
<button
|
||||
class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden"
|
||||
onclick={() => (sidebarOpen = false)}
|
||||
aria-label="Sidebar schließen"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="flex w-80 flex-shrink-0 flex-col border-r border-black/10 dark:border-white/10 bg-white/50 dark:bg-white/5 backdrop-blur-sm transition-all duration-300 ease-in-out"
|
||||
class:hidden={!sidebarOpen}
|
||||
class:lg:flex={true}
|
||||
class="flex flex-col border-r border-black/10 dark:border-white/10 bg-white/95 dark:bg-zinc-900/95 backdrop-blur-xl transition-all duration-300 ease-in-out
|
||||
fixed lg:relative inset-y-0 left-0 z-50 lg:z-auto
|
||||
w-[85vw] max-w-[320px] lg:w-80 lg:max-w-none
|
||||
{sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}"
|
||||
>
|
||||
<!-- User Info / Status Bar -->
|
||||
<div class="border-b border-black/10 dark:border-white/10 px-4 py-3">
|
||||
|
|
@ -66,7 +99,10 @@
|
|||
|
||||
<!-- Room List -->
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<RoomList onCreateRoom={() => (showCreateRoom = true)} />
|
||||
<RoomList
|
||||
onCreateRoom={() => (showCreateRoom = true)}
|
||||
onSelectRoom={selectRoomAndCloseSidebar}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
| Priorität | Bereich | Geschätzte Einsparung | Aufwand |
|
||||
|-----------|---------|----------------------|---------|
|
||||
| ~~**KRITISCH**~~ | ~~Backend Metrics Migration~~ | ~~350 LOC~~ ✅ **709 LOC entfernt** | ~~Niedrig~~ |
|
||||
| **HOCH** | Skeleton Components | 800-1.000 LOC | Mittel |
|
||||
| ~~**HOCH**~~ | ~~Skeleton Components~~ | ~~800-1.000 LOC~~ → **20 LOC** | ~~Mittel~~ → Analysiert ✅ |
|
||||
| ~~**HOCH**~~ | ~~App Settings Stores~~ | ~~600-700 LOC~~ ✅ **323 LOC entfernt** | ~~Mittel~~ |
|
||||
| ~~**HOCH**~~ | ~~Main.ts/CORS Patterns~~ | ~~1.800 LOC~~ ✅ **~280 LOC entfernt** | ~~Mittel~~ |
|
||||
| ~~**MITTEL**~~ | ~~TypeScript Configs~~ | ~~400 LOC~~ ✅ **~280 LOC entfernt** | ~~Niedrig~~ |
|
||||
|
|
@ -232,21 +232,31 @@ export const { isSidebarMode, isNavCollapsed } = createSimpleNavigationStores({
|
|||
|
||||
## 3. UI Components
|
||||
|
||||
### 3.1 HOCH: Skeleton Components (800-1.000 LOC)
|
||||
### ~~3.1 HOCH: Skeleton Components~~ ✅ ANALYSIERT (Minimal ~20 LOC gespart)
|
||||
|
||||
**Problem:** 31 Skeleton-Komponenten über Apps verteilt, obwohl shared-ui Primitives hat.
|
||||
**Status:** Nach detaillierter Analyse: Die meisten Skeletons sind legitime Domain-Customizations (29.01.2026)
|
||||
|
||||
**Betroffene Apps:**
|
||||
- `apps/contacts/` - 11 Skeletons (925 LOC)
|
||||
- `apps/calendar/` - 5 Skeletons (338 LOC)
|
||||
- `apps/todo/` - 5 Skeletons
|
||||
**Ergebnis der Analyse:**
|
||||
- 29 Skeleton-Dateien über 5 Apps (calendar, clock, contacts, todo, questions)
|
||||
- **Fazit:** Skeletons nutzen bereits shared-ui Primitives (`SkeletonBox`) korrekt
|
||||
- Domain-spezifische Layouts (Kalender-Grid, Uhr-Circle) gehören NICHT in shared-ui
|
||||
|
||||
**Durchgeführte Konsolidierung:**
|
||||
- ✅ `calculateFadeOpacity()` Utility nach `@manacore/shared-ui` verschoben
|
||||
- ✅ Contacts App nutzt jetzt die shared-ui Utility
|
||||
- ✅ Lokale `utils.ts` in contacts gelöscht (~20 LOC gespart)
|
||||
|
||||
**Warum KEINE weitere Konsolidierung:**
|
||||
| Skeleton | LOC | Status |
|
||||
|----------|-----|--------|
|
||||
| Calendar AppLoadingSkeleton | 56 | Keep - 7-Spalten Kalender-Grid Layout |
|
||||
| Clock AppLoadingSkeleton | 91 | Keep - 300px kreisförmiger Uhr-Platzhalter |
|
||||
| ContactRowSkeleton, TaskItemSkeleton, etc. | ~800 | Keep - Legitime Domain-spezifische Komponenten |
|
||||
|
||||
**Shared-UI hat bereits:**
|
||||
- `SkeletonBox`, `SkeletonAvatar`, `SkeletonCard`, `SkeletonGrid`, `SkeletonList`, `SkeletonRow`, `SkeletonText`
|
||||
|
||||
**Empfehlung:**
|
||||
1. Dokumentation für Skeleton-Komposition aus Primitives
|
||||
2. Page-Level Presets erstellen: `ListPageSkeleton`, `DetailPageSkeleton`, `GridPageSkeleton`
|
||||
- `AppLoadingSkeleton` mit 5 Layout-Presets (list, centered, sidebar, tasks, minimal)
|
||||
- `calculateFadeOpacity()` Utility für Fade-Effekte
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -463,12 +473,14 @@ export default createDrizzleConfig({ dbName: 'chat' });
|
|||
| `@manacore/shared-nestjs-health` erstellen | 170 | Niedrig | Offen |
|
||||
| ~~Drizzle Config Factory erstellen~~ | ~~200~~ → **160** | ~~Niedrig~~ | ✅ Erledigt |
|
||||
|
||||
### Phase 4: Skeleton Refactoring (Optional, ~800 LOC)
|
||||
### ~~Phase 4: Skeleton Refactoring~~ ✅ ANALYSIERT
|
||||
|
||||
| Aufgabe | LOC | Aufwand |
|
||||
|---------|-----|---------|
|
||||
| Page-Level Skeleton Presets erstellen | 400 | Mittel |
|
||||
| Bestehende Skeletons refactoren | 400 | Mittel |
|
||||
| Aufgabe | LOC | Aufwand | Status |
|
||||
|---------|-----|---------|--------|
|
||||
| ~~Page-Level Skeleton Presets~~ | ~~400~~ → **0** | ~~Mittel~~ | Nicht nötig - Shared-UI hat bereits 5 Presets |
|
||||
| ~~Bestehende Skeletons refactoren~~ | ~~400~~ → **20** | ~~Mittel~~ | ✅ Nur `calculateFadeOpacity` Utility |
|
||||
|
||||
**Ergebnis:** Domain-spezifische Skeletons sind korrekt designed. Keine große Konsolidierung nötig.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"drizzle-kit": "^0.30.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
*/
|
||||
|
||||
import SkeletonRow from './SkeletonRow.svelte';
|
||||
import { calculateFadeOpacity } from './utils';
|
||||
|
||||
interface Props {
|
||||
/** Number of rows to show */
|
||||
|
|
@ -38,15 +39,13 @@
|
|||
class: className = '',
|
||||
}: Props = $props();
|
||||
|
||||
function calculateOpacity(index: number): number {
|
||||
if (!fadeEffect) return 1;
|
||||
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
|
||||
return Math.max(minOpacity, 1 - index * fadeStep);
|
||||
function getOpacity(index: number): number {
|
||||
return fadeEffect ? calculateFadeOpacity(index, count, minOpacity) : 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="skeleton-list flex flex-col {className}" style="gap: {gap};">
|
||||
{#each Array(count) as _, i}
|
||||
<SkeletonRow {showAvatar} {avatarSize} opacity={calculateOpacity(i)} />
|
||||
<SkeletonRow {showAvatar} {avatarSize} opacity={getOpacity(i)} />
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -29,3 +29,6 @@ export { default as SkeletonGrid } from './SkeletonGrid.svelte';
|
|||
|
||||
// Full Page
|
||||
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';
|
||||
|
||||
// Utilities
|
||||
export { calculateFadeOpacity } from './utils';
|
||||
|
|
|
|||
30
packages/shared-ui/src/molecules/loaders/utils.ts
Normal file
30
packages/shared-ui/src/molecules/loaders/utils.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Skeleton utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Calculate opacity for cascading fade effect in skeleton lists
|
||||
*
|
||||
* Creates a smooth fade from 1 at the first item to minOpacity at the last item.
|
||||
*
|
||||
* @param index Current item index (0-based)
|
||||
* @param count Total number of items
|
||||
* @param minOpacity Minimum opacity at the last item (default: 0.3)
|
||||
* @returns Opacity value between minOpacity and 1
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // For a list of 5 items with minOpacity 0.3:
|
||||
* // index 0 → 1.0
|
||||
* // index 1 → 0.825
|
||||
* // index 2 → 0.65
|
||||
* // index 3 → 0.475
|
||||
* // index 4 → 0.3
|
||||
* calculateFadeOpacity(0, 5, 0.3) // 1.0
|
||||
* calculateFadeOpacity(4, 5, 0.3) // 0.3
|
||||
* ```
|
||||
*/
|
||||
export function calculateFadeOpacity(index: number, count: number, minOpacity = 0.3): number {
|
||||
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
|
||||
return Math.max(minOpacity, 1 - index * fadeStep);
|
||||
}
|
||||
1064
pnpm-lock.yaml
generated
1064
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue