Paket A: Vollständige Emoji-Auswahl für Notiz-Icons mit Lazy-Loading und Mobile Bottom-Sheet

This commit is contained in:
sandrodach committed 2026-10-28 22:44:21 +01:00
1 parent a354f3d008
commit 633bd6f1ef
5 files changed
+282 -132

No files matched your search

@@ -0,0 +1,226 @@
import React, { Suspense, useEffect, useRef, useState } from 'react';
import { X, Trash2, Loader2, Clock } from 'lucide-react';
import type { EmojiClickData } from 'emoji-picker-react';
import { EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react';
// Lazy-load emoji-picker-react so the main bundle size stays small
const EmojiPicker = React.lazy(() => import('emoji-picker-react'));
const RECENT_EMOJIS_STORAGE_KEY = 'mindsift_recent_emojis';
const MAX_RECENT_EMOJIS = 10;
function getStoredRecentEmojis(): string[] {
try {
const raw = localStorage.getItem(RECENT_EMOJIS_STORAGE_KEY);
if (!raw) return ['📝', '💡', '🚀', '⭐', '🔥', '🎯', '📚', '💻'];
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed.slice(0, MAX_RECENT_EMOJIS);
}
} catch {
// fallback
}
return ['📝', '💡', '🚀', '⭐', '🔥', '🎯', '📚', '💻'];
}
function saveRecentEmoji(emoji: string): void {
try {
const current = getStoredRecentEmojis();
const updated = [emoji, ...current.filter((e) => e !== emoji)].slice(0, MAX_RECENT_EMOJIS);
localStorage.setItem(RECENT_EMOJIS_STORAGE_KEY, JSON.stringify(updated));
} catch {
// ignore
}
}
interface EmojiPickerModalProps {
currentIcon?: string;
onSelectEmoji: (emoji: string) => void;
onClose: () => void;
}
export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
currentIcon,
onSelectEmoji,
onClose,
}) => {
const [recentEmojis, setRecentEmojis] = useState<string[]>(getStoredRecentEmojis);
const [isDark, setIsDark] = useState<boolean>(() =>
typeof document !== 'undefined'
? document.documentElement.classList.contains('dark')
: true
);
const containerRef = useRef<HTMLDivElement>(null);
// Sync theme changes
useEffect(() => {
const observer = new MutationObserver(() => {
setIsDark(document.documentElement.classList.contains('dark'));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'data-theme'],
});
return () => observer.disconnect();
}, []);
// Close on Escape or click outside (desktop popover)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
const handleClickOutside = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
document.addEventListener('mousedown', handleClickOutside);
return () => {
window.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
const handlePick = (emoji: string) => {
saveRecentEmoji(emoji);
setRecentEmojis(getStoredRecentEmojis());
onSelectEmoji(emoji);
};
const handlePickerEmojiClick = (emojiData: EmojiClickData) => {
handlePick(emojiData.emoji);
};
return (
<>
{/* Mobile Backdrop (< 640px) */}
<div
className="fixed inset-0 bg-black/60 z-40 sm:hidden backdrop-blur-xs"
onClick={onClose}
aria-hidden="true"
/>
{/* Main Container: Mobile Bottom-Sheet (<640px) & Desktop Popover (>=640px) */}
<div
ref={containerRef}
className="
fixed inset-x-0 bottom-0 z-50 rounded-t-2xl max-h-[85vh] bg-panel border-t border-border shadow-2xl flex flex-col p-3 space-y-3
sm:absolute sm:inset-auto sm:left-0 sm:top-full sm:mt-1 sm:z-40 sm:w-[360px] sm:max-h-[520px] sm:rounded-card sm:border sm:border-border sm:shadow-xl
"
style={
{
// Inject MindSift CSS variables into emoji-picker-react for seamless theming
'--epr-bg-color': 'var(--bg-panel)',
'--epr-category-label-bg-color': 'var(--bg-panel)',
'--epr-picker-border-color': 'var(--border-color)',
'--epr-text-color': 'var(--text-main)',
'--epr-search-input-bg-color': 'var(--bg-app)',
'--epr-search-input-bg-color-active': 'var(--bg-panel-elevated)',
'--epr-hover-bg-color': 'var(--bg-panel-elevated)',
'--epr-focus-bg-color': 'var(--bg-panel-elevated)',
'--epr-highlight-color': 'var(--accent-primary)',
'--epr-category-icon-active-color': 'var(--accent-primary)',
} as React.CSSProperties
}
>
{/* Mobile handle indicator */}
<div className="w-10 h-1 bg-border rounded-full mx-auto sm:hidden shrink-0" />
{/* Top Header: Title, Actions & Close */}
<div className="flex items-center justify-between gap-2 shrink-0 border-b border-border pb-2">
<div className="flex items-center gap-1.5 text-xs font-semibold text-main">
<span>Icon auswählen</span>
{currentIcon && (
<span className="text-base leading-none ml-1">{currentIcon}</span>
)}
</div>
<div className="flex items-center gap-1">
{currentIcon && (
<button
type="button"
onClick={() => onSelectEmoji('')}
className="h-11 sm:h-7 px-3 sm:px-2 rounded text-xs font-medium text-status-err hover:bg-status-err/10 flex items-center gap-1 transition-colors cursor-pointer"
title="Icon entfernen"
>
<Trash2 className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Entfernen</span>
</button>
)}
<button
type="button"
onClick={onClose}
className="w-11 h-11 sm:w-7 sm:h-7 rounded flex items-center justify-center text-muted hover:text-main hover:bg-panel-elevated transition-colors cursor-pointer"
title="Schließen"
aria-label="Schließen"
>
<X className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
</button>
</div>
</div>
{/* Recent Emojis Bar */}
{recentEmojis.length > 0 && (
<div className="shrink-0 space-y-1.5">
<div className="flex items-center gap-1 text-[11px] text-muted">
<Clock className="w-3 h-3 text-accent" />
<span>Zuletzt verwendet:</span>
</div>
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 no-scrollbar">
{recentEmojis.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => handlePick(emoji)}
className="min-w-[44px] min-h-[44px] sm:min-w-[32px] sm:min-h-[32px] rounded-btn bg-panel-elevated hover:bg-canvas border border-border flex items-center justify-center text-xl sm:text-base cursor-pointer transition-transform active:scale-95 shrink-0"
>
{emoji}
</button>
))}
</div>
</div>
)}
{/* Full Emoji Catalog via emoji-picker-react */}
<div className="flex-1 min-h-[300px] overflow-hidden rounded mindsift-emoji-picker">
<Suspense
fallback={
<div className="h-64 flex flex-col items-center justify-center gap-2 text-xs text-muted">
<Loader2 className="w-5 h-5 animate-spin text-accent" />
<span>Emoji-Katalog wird geladen…</span>
</div>
}
>
<EmojiPicker
onEmojiClick={handlePickerEmojiClick}
autoFocusSearch={true}
theme={isDark ? Theme.DARK : Theme.LIGHT}
emojiStyle={EmojiStyle.NATIVE}
suggestedEmojisMode={SuggestionMode.RECENT}
searchPlaceholder="Emoji suchen…"
searchPlaceHolder="Emoji suchen…"
width="100%"
height="350px"
previewConfig={{
showPreview: false,
}}
skinTonesDisabled={false}
lazyLoadEmojis={true}
/>
</Suspense>
</div>
</div>
</>
);
};