fix(mobile): sheets above the keyboard, emoji picker, cleaner header
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m25s
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>
This commit is contained in:
1 parent
108d166990
commit
eaabe469d5
10 files changed
+249
-217
No files matched your search
@@ -716,9 +716,6 @@ export const App: React.FC = () => {
|
||||
</span>
|
||||
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-accent bg-accent/15 px-1.5 py-0.5 rounded-md">
|
||||
{activeTabLabelMap[activeTab]}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-muted transition-transform duration-150 ${
|
||||
isMenuOpen ? 'rotate-180 text-accent' : ''
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useKeyboardInset } from '../lib/useKeyboardInset';
|
||||
|
||||
interface FormSheetProps {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
onClose: () => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
children: React.ReactNode; // the fields
|
||||
submitLabel?: string;
|
||||
// Desktop only: open next to the click instead of centred
|
||||
anchor?: { x: number; y: number } | null;
|
||||
}
|
||||
|
||||
// Small create/rename dialogs. Phone: bottom sheet directly above the keyboard, full width, large
|
||||
// fields. Desktop: centred dialog (or next to the click). Escape and the backdrop close it.
|
||||
export const FormSheet: React.FC<FormSheetProps> = ({ title, icon, onClose, onSubmit, children, submitLabel = 'Erstellen', anchor }) => {
|
||||
const { inset, visibleHeight } = useKeyboardInset();
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const desktopAnchor = anchor
|
||||
? ({ '--sheet-left': `${anchor.x}px`, '--sheet-top': `${anchor.y}px` } as React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 !m-0 z-50 modal-overlay" onClick={onClose}>
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={
|
||||
{
|
||||
...desktopAnchor,
|
||||
'--kb-inset': `${inset}px`,
|
||||
'--vv-max': visibleHeight ? `${visibleHeight - 16}px` : '85vh',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={`fixed inset-x-0 bottom-[var(--kb-inset)] max-h-[var(--vv-max)] panel-surface rounded-t-2xl border-t border-border shadow-panel p-4 space-y-3 overflow-y-auto
|
||||
sm:inset-x-auto sm:bottom-auto sm:max-h-[85vh] sm:w-full sm:rounded-card sm:border ${
|
||||
anchor
|
||||
? 'sm:max-w-[16.5rem] sm:left-[var(--sheet-left)] sm:top-[var(--sheet-top)]'
|
||||
: 'sm:max-w-sm sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2'
|
||||
}`}
|
||||
>
|
||||
<div className="w-10 h-1 bg-border rounded-full mx-auto sm:hidden" aria-hidden="true" />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5 min-w-0">
|
||||
{icon}
|
||||
<span className="truncate">{title}</span>
|
||||
</h3>
|
||||
<button type="button" onClick={onClose} className="w-11 h-11 sm:w-7 sm:h-7 -mr-2 sm:mr-0 flex items-center justify-center text-muted hover:text-main" title="Schließen">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
<div className="flex gap-2 pt-1 sm:justify-end">
|
||||
<button type="button" onClick={onClose} className="btn-action flex-1 sm:flex-none h-11 sm:h-8 px-3 text-sm sm:text-xs">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" className="btn-primary flex-1 sm:flex-none h-11 sm:h-8 px-4 text-sm sm:text-xs font-semibold">
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
{/* keeps the buttons clear of the home indicator when no keyboard is open */}
|
||||
<div className="sm:hidden" style={{ height: inset ? 0 : 'env(safe-area-inset-bottom, 0px)' }} />
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -15,6 +15,7 @@ import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, Realti
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
import { FormSheet } from '../../components/FormSheet';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
|
||||
interface SharedListsViewProps {
|
||||
@@ -1017,61 +1018,35 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
</div>
|
||||
|
||||
{showNewListModal && (
|
||||
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm panel-surface shadow-panel p-4 space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-border pb-2">
|
||||
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5">
|
||||
<ShoppingCart className="w-4 h-4 text-accent" />
|
||||
<span>Neue Einkaufsliste erstellen</span>
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewListModal(false)}
|
||||
className="text-muted hover:text-main"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<FormSheet
|
||||
title="Neue Einkaufsliste erstellen"
|
||||
icon={<ShoppingCart className="w-4 h-4 text-accent" />}
|
||||
onClose={() => setShowNewListModal(false)}
|
||||
onSubmit={handleCreateList}
|
||||
>
|
||||
<div>
|
||||
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newListIcon}
|
||||
onChange={(e) => setNewListIcon(e.target.value)}
|
||||
maxLength={2}
|
||||
className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm"
|
||||
title="Icon (Emoji)"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newListTitle}
|
||||
onChange={(e) => setNewListTitle(e.target.value)}
|
||||
placeholder="z.B. Supermarkt, Baumarkt, Drogerie"
|
||||
autoFocus
|
||||
required
|
||||
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2.5 text-base sm:text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateList} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newListIcon}
|
||||
onChange={(e) => setNewListIcon(e.target.value)}
|
||||
maxLength={2}
|
||||
className="w-10 h-8 text-center input-field text-sm"
|
||||
title="Icon (Emoji)"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newListTitle}
|
||||
onChange={(e) => setNewListTitle(e.target.value)}
|
||||
placeholder="z.B. Supermarkt, Baumarkt, Drogerie"
|
||||
autoFocus
|
||||
required
|
||||
className="flex-1 h-8 input-field px-2.5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewListModal(false)}
|
||||
className="btn-action h-7 px-3 text-xs"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" className="btn-primary h-7 px-3 text-xs">
|
||||
Erstellen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</FormSheet>
|
||||
)}
|
||||
|
||||
{/* Active Shared List View */}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { TaskDetailModal } from './TaskDetailModal';
|
||||
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
|
||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
import { FormSheet } from '../../components/FormSheet';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
|
||||
interface TasksViewProps {
|
||||
@@ -2016,60 +2017,35 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
{/* NEW LIST MODAL */}
|
||||
{showNewListModal && (
|
||||
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm panel-surface shadow-panel p-4 space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-border pb-2">
|
||||
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5">
|
||||
<Layers className="w-4 h-4 text-accent" />
|
||||
<span>Neue Aufgabenliste erstellen</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowNewListModal(false)}
|
||||
className="text-muted hover:text-main"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<FormSheet
|
||||
title="Neue Aufgabenliste erstellen"
|
||||
icon={<Layers className="w-4 h-4 text-accent" />}
|
||||
onClose={() => setShowNewListModal(false)}
|
||||
onSubmit={handleCreateTaskList}
|
||||
>
|
||||
<div>
|
||||
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newListIcon}
|
||||
onChange={(e) => setNewListIcon(e.target.value)}
|
||||
maxLength={2}
|
||||
className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm"
|
||||
title="Icon (Emoji)"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newListTitle}
|
||||
onChange={(e) => setNewListTitle(e.target.value)}
|
||||
placeholder="z.B. Homelab, Einkäufe, Steuern"
|
||||
autoFocus
|
||||
required
|
||||
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2.5 text-base sm:text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateTaskList} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newListIcon}
|
||||
onChange={(e) => setNewListIcon(e.target.value)}
|
||||
maxLength={2}
|
||||
className="w-10 h-8 text-center input-field text-sm"
|
||||
title="Icon (Emoji)"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newListTitle}
|
||||
onChange={(e) => setNewListTitle(e.target.value)}
|
||||
placeholder="z.B. Homelab, Einkäufe, Steuern"
|
||||
autoFocus
|
||||
required
|
||||
className="flex-1 h-8 input-field px-2.5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewListModal(false)}
|
||||
className="btn-action h-7 px-3 text-xs"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" className="btn-primary h-7 px-3 text-xs">
|
||||
Erstellen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</FormSheet>
|
||||
)}
|
||||
|
||||
{/* UNIFIED SHARE LIST MODAL */}
|
||||
|
||||
@@ -1,37 +1,25 @@
|
||||
import React, { Suspense, useEffect, useRef, useState } from 'react';
|
||||
import { X, Trash2, Loader2, Clock } from 'lucide-react';
|
||||
import { X, Trash2, Loader2 } from 'lucide-react';
|
||||
import type { EmojiClickData } from 'emoji-picker-react';
|
||||
import { EmojiStyle, Theme, SuggestionMode } 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'));
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
@@ -44,7 +32,10 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
onSelectEmoji,
|
||||
onClose,
|
||||
}) => {
|
||||
const [recentEmojis, setRecentEmojis] = useState<string[]>(getStoredRecentEmojis);
|
||||
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')
|
||||
@@ -92,8 +83,6 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
}, [onClose]);
|
||||
|
||||
const handlePick = (emoji: string) => {
|
||||
saveRecentEmoji(emoji);
|
||||
setRecentEmojis(getStoredRecentEmojis());
|
||||
onSelectEmoji(emoji);
|
||||
};
|
||||
|
||||
@@ -114,7 +103,7 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
<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
|
||||
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={
|
||||
@@ -130,6 +119,8 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
'--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
|
||||
}
|
||||
>
|
||||
@@ -170,30 +161,8 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
</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">
|
||||
<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">
|
||||
@@ -211,7 +180,8 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
searchPlaceholder="Emoji suchen…"
|
||||
searchPlaceHolder="Emoji suchen…"
|
||||
width="100%"
|
||||
height="350px"
|
||||
height={`${pickerHeight}px`}
|
||||
categories={CATEGORIES as any}
|
||||
previewConfig={{
|
||||
showPreview: false,
|
||||
}}
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
RealtimeTransport,
|
||||
} from '../../lib/api';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { FormSheet } from '../../components/FormSheet';
|
||||
import { sendDocOps } from '../../lib/docSync/oneoff';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
|
||||
@@ -340,7 +341,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
if (e) {
|
||||
setNewFolderCoords({
|
||||
x: Math.min(e.clientX, window.innerWidth - 270),
|
||||
y: Math.min(e.clientY, window.innerHeight - 130),
|
||||
y: Math.min(e.clientY, window.innerHeight - 200),
|
||||
});
|
||||
} else {
|
||||
setNewFolderCoords({ x: 100, y: 150 });
|
||||
@@ -2196,55 +2197,22 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
{/* New Folder Popover (small & positioned near click, asks only for name & OK) */}
|
||||
{showNewFolderModal && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/20"
|
||||
onClick={() => setShowNewFolderModal(false)}
|
||||
<FormSheet
|
||||
title={newFolderParentPath ? `Unterordner in "${newFolderParentPath}"` : 'Neuer Ordner'}
|
||||
icon={<FolderPlus className="w-4 h-4 text-accent" />}
|
||||
onClose={() => setShowNewFolderModal(false)}
|
||||
onSubmit={handleCreateFolderSubmit}
|
||||
anchor={newFolderCoords}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleCreateFolderSubmit}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={
|
||||
newFolderCoords
|
||||
? { top: `${newFolderCoords.y}px`, left: `${newFolderCoords.x}px` }
|
||||
: undefined
|
||||
}
|
||||
className="fixed w-64 bg-panel border border-border rounded-card p-3 shadow-lg space-y-2.5 z-50 text-xs"
|
||||
>
|
||||
<div className="font-semibold text-main text-xs flex items-center justify-between">
|
||||
<span className="truncate">{newFolderParentPath ? `Unterordner in "${newFolderParentPath}"` : 'Neuer Ordner'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewFolderModal(false)}
|
||||
className="text-faint hover:text-main cursor-pointer"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
placeholder="Ordnername"
|
||||
className="input-field w-full px-2 py-1 text-xs"
|
||||
/>
|
||||
<div className="flex justify-end gap-1.5 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewFolderModal(false)}
|
||||
className="btn-action px-2.5 py-1 text-xs"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary px-3 py-1 text-xs font-semibold"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
placeholder="Ordnername"
|
||||
className="input-field w-full h-11 sm:h-8 px-2.5 text-base sm:text-xs"
|
||||
/>
|
||||
</FormSheet>
|
||||
)}
|
||||
|
||||
{/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */}
|
||||
|
||||
@@ -484,7 +484,10 @@ body {
|
||||
min-height: 44px !important;
|
||||
}
|
||||
|
||||
.mindsift-emoji-picker .EmojiPickerReact .epr-category-nav button {
|
||||
min-height: 44px !important;
|
||||
/* Category icons are sprite cut-outs: they must keep the library's square size (stretching them showed
|
||||
two icons and an oval highlight). More breathing room comes from the nav row instead. */
|
||||
.mindsift-emoji-picker .EmojiPickerReact .epr-category-nav {
|
||||
padding-top: 6px !important;
|
||||
padding-bottom: 6px !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useKeyboardInset } from '../useKeyboardInset';
|
||||
|
||||
// Simulates iOS: the layout viewport keeps its height, the visual viewport shrinks by the keyboard
|
||||
function mockVisualViewport(height: number) {
|
||||
const listeners: Record<string, (() => void)[]> = {};
|
||||
const vv = {
|
||||
height,
|
||||
offsetTop: 0,
|
||||
addEventListener: (t: string, fn: () => void) => (listeners[t] = [...(listeners[t] || []), fn]),
|
||||
removeEventListener: (t: string, fn: () => void) => (listeners[t] = (listeners[t] || []).filter((f) => f !== fn)),
|
||||
};
|
||||
Object.defineProperty(window, 'visualViewport', { value: vv, configurable: true });
|
||||
Object.defineProperty(window, 'innerHeight', { value: 844, configurable: true });
|
||||
return {
|
||||
open(keyboard: number) {
|
||||
vv.height = 844 - keyboard;
|
||||
(listeners.resize || []).forEach((fn) => fn());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('useKeyboardInset', () => {
|
||||
it('reports the keyboard height so sheets can sit above it', () => {
|
||||
const vv = mockVisualViewport(844);
|
||||
const { result } = renderHook(() => useKeyboardInset());
|
||||
expect(result.current.inset).toBe(0);
|
||||
act(() => vv.open(336));
|
||||
expect(result.current.inset).toBe(336);
|
||||
expect(result.current.visibleHeight).toBe(508);
|
||||
act(() => vv.open(0));
|
||||
expect(result.current.inset).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// Space the on-screen keyboard takes at the bottom (0 without keyboard) and the height that stays
|
||||
// visible. Fixed bottom sheets use it to sit right above the keyboard instead of behind it (iOS keeps
|
||||
// fixed elements on the layout viewport; on Android the inset is usually 0 because the page shrinks).
|
||||
export function useKeyboardInset(): { inset: number; visibleHeight: number } {
|
||||
const read = () => {
|
||||
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
||||
if (!vv) return { inset: 0, visibleHeight: typeof window !== 'undefined' ? window.innerHeight : 0 };
|
||||
return { inset: Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)), visibleHeight: Math.round(vv.height) };
|
||||
};
|
||||
const [state, setState] = useState(read);
|
||||
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
const update = () => setState(read());
|
||||
vv.addEventListener('resize', update);
|
||||
vv.addEventListener('scroll', update);
|
||||
update();
|
||||
return () => {
|
||||
vv.removeEventListener('resize', update);
|
||||
vv.removeEventListener('scroll', update);
|
||||
};
|
||||
}, []);
|
||||
return state;
|
||||
}
|
||||
Reference in new issue
Block a user