MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m25s
- FormSheet: create dialogs (task list, shopping list, folder) as bottom sheet right above the on-screen keyboard (visualViewport), large fields; centred or anchored dialog on desktop - Emoji picker: category icons keep their sprite size (no doubled icons, round highlight), one 'Zuletzt verwendet' (library category, German names), sheet and picker height follow the keyboard - Mobile header no longer repeats the current section next to the logo Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
197 lines
7.4 KiB
TypeScript
197 lines
7.4 KiB
TypeScript
import React, { Suspense, useEffect, useRef, useState } from 'react';
|
|
import { X, Trash2, Loader2 } from 'lucide-react';
|
|
import type { EmojiClickData } from 'emoji-picker-react';
|
|
import { Categories, EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react';
|
|
import { useKeyboardInset } from '../../lib/useKeyboardInset';
|
|
|
|
// Lazy-load emoji-picker-react so the main bundle size stays small
|
|
const EmojiPicker = React.lazy(() => import('emoji-picker-react'));
|
|
|
|
// The library's own "recently used" category (stored by it) is the only recent list; a second bar
|
|
// would cost height on phones. Category names in German.
|
|
const CATEGORIES = [
|
|
{ category: Categories.SUGGESTED, name: 'Zuletzt verwendet' },
|
|
{ category: Categories.SMILEYS_PEOPLE, name: 'Smileys & Personen' },
|
|
{ category: Categories.ANIMALS_NATURE, name: 'Tiere & Natur' },
|
|
{ category: Categories.FOOD_DRINK, name: 'Essen & Trinken' },
|
|
{ category: Categories.TRAVEL_PLACES, name: 'Reisen & Orte' },
|
|
{ category: Categories.ACTIVITIES, name: 'Aktivitäten' },
|
|
{ category: Categories.OBJECTS, name: 'Objekte' },
|
|
{ category: Categories.SYMBOLS, name: 'Symbole' },
|
|
{ category: Categories.FLAGS, name: 'Flaggen' },
|
|
];
|
|
|
|
interface EmojiPickerModalProps {
|
|
currentIcon?: string;
|
|
onSelectEmoji: (emoji: string) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|
currentIcon,
|
|
onSelectEmoji,
|
|
onClose,
|
|
}) => {
|
|
const { inset, visibleHeight } = useKeyboardInset();
|
|
const isPhone = typeof window !== 'undefined' && window.innerWidth < 640;
|
|
// On phones the picker shrinks with the keyboard so search field and results stay visible
|
|
const pickerHeight = isPhone ? Math.max(200, Math.min(350, visibleHeight - 110)) : 350;
|
|
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) => {
|
|
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-[var(--kb-inset)] z-50 rounded-t-2xl max-h-[var(--vv-max)] 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)',
|
|
'--kb-inset': `${inset}px`,
|
|
'--vv-max': `${Math.round(visibleHeight * 0.9)}px`,
|
|
} 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>
|
|
|
|
{/* Full Emoji Catalog via emoji-picker-react */}
|
|
<div className="flex-1 min-h-0 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={`${pickerHeight}px`}
|
|
categories={CATEGORIES as any}
|
|
previewConfig={{
|
|
showPreview: false,
|
|
}}
|
|
skinTonesDisabled={false}
|
|
lazyLoadEmojis={true}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|