diff --git a/Doku/ui-patterns.md b/Doku/ui-patterns.md index 6fe68b2..c40bea5 100644 --- a/Doku/ui-patterns.md +++ b/Doku/ui-patterns.md @@ -39,6 +39,14 @@ Komponente: [ToggleSwitch.tsx](../frontend/src/components/ToggleSwitch.tsx) - Ein/Aus-Einstellungen sind immer Schalter, keine Checkbox-Haken (Einrichtung, Admin-Bereich). - Text links (Titel, darunter eine kurze Erklärung in `text-muted`), Schalter rechts. +## Kleine Dialoge (Anlegen, Umbenennen) + +Komponente: [FormSheet.tsx](../frontend/src/components/FormSheet.tsx), Tastatur-Abstand über [useKeyboardInset.ts](../frontend/src/lib/useKeyboardInset.ts) + +- **Handy:** Bottom-Sheet über die volle Breite, das direkt über der Bildschirmtastatur sitzt (der Abstand kommt aus `visualViewport`), Griff oben, Felder und Buttons mindestens 44 px hoch, Eingaben in 16 px (sonst zoomt iOS), Abbrechen/Erstellen nebeneinander über die ganze Breite. +- **Desktop:** zentriertes Fenster, oder mit `anchor` neben der Klickposition (z. B. „Neuer Ordner“ im Explorer). +- Escape und Klick auf den Hintergrund schließen. Genutzt für neue Aufgabenliste, neue Einkaufsliste und neuen Ordner; das Emoji-Sheet nutzt denselben Tastatur-Abstand. + ## Zähler Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Klassen `count-badge*` in [index.css](../frontend/src/index.css). Es gibt keine anderen Zähler-Stile mehr. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9124344..5715ba2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -716,9 +716,6 @@ export const App: React.FC = () => { {APP_VERSION} - - {activeTabLabelMap[activeTab]} - 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 = ({ 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 ( +
+
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' + }`} + > +
{showNewListModal && ( -
-
-
-

- - Neue Einkaufsliste erstellen -

- + } + onClose={() => setShowNewListModal(false)} + onSubmit={handleCreateList} + > +
+ +
+ 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)" + /> + 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" + /> +
- -
-
- -
- setNewListIcon(e.target.value)} - maxLength={2} - className="w-10 h-8 text-center input-field text-sm" - title="Icon (Emoji)" - /> - setNewListTitle(e.target.value)} - placeholder="z.B. Supermarkt, Baumarkt, Drogerie" - autoFocus - required - className="flex-1 h-8 input-field px-2.5 text-xs" - /> -
-
- -
- - -
-
-
-
+ )} {/* Active Shared List View */} diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 320d3a9..786ec43 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -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 = ({ {/* NEW LIST MODAL */} {showNewListModal && ( -
-
-
-

- - Neue Aufgabenliste erstellen -

- + } + onClose={() => setShowNewListModal(false)} + onSubmit={handleCreateTaskList} + > +
+ +
+ 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)" + /> + 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" + /> +
- -
-
- -
- setNewListIcon(e.target.value)} - maxLength={2} - className="w-10 h-8 text-center input-field text-sm" - title="Icon (Emoji)" - /> - 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" - /> -
-
- -
- - -
-
-
-
+ )} {/* UNIFIED SHARE LIST MODAL */} diff --git a/frontend/src/features/workspace/EmojiPickerModal.tsx b/frontend/src/features/workspace/EmojiPickerModal.tsx index 9646fbc..84b315b 100644 --- a/frontend/src/features/workspace/EmojiPickerModal.tsx +++ b/frontend/src/features/workspace/EmojiPickerModal.tsx @@ -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 = ({ onSelectEmoji, onClose, }) => { - const [recentEmojis, setRecentEmojis] = useState(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(() => typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') @@ -92,8 +83,6 @@ export const EmojiPickerModal: React.FC = ({ }, [onClose]); const handlePick = (emoji: string) => { - saveRecentEmoji(emoji); - setRecentEmojis(getStoredRecentEmojis()); onSelectEmoji(emoji); }; @@ -114,7 +103,7 @@ export const EmojiPickerModal: React.FC = ({
= ({ '--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 = ({
- {/* Recent Emojis Bar */} - {recentEmojis.length > 0 && ( -
-
- - Zuletzt verwendet: -
-
- {recentEmojis.map((emoji) => ( - - ))} -
-
- )} - {/* Full Emoji Catalog via emoji-picker-react */} -
+
@@ -211,7 +180,8 @@ export const EmojiPickerModal: React.FC = ({ searchPlaceholder="Emoji suchen…" searchPlaceHolder="Emoji suchen…" width="100%" - height="350px" + height={`${pickerHeight}px`} + categories={CATEGORIES as any} previewConfig={{ showPreview: false, }} diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index ad4ff2e..d469d02 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -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 = ({ 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 = ({ {/* New Folder Popover (small & positioned near click, asks only for name & OK) */} {showNewFolderModal && ( -
setShowNewFolderModal(false)} + } + onClose={() => setShowNewFolderModal(false)} + onSubmit={handleCreateFolderSubmit} + anchor={newFolderCoords} > -
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" - > -
- {newFolderParentPath ? `Unterordner in "${newFolderParentPath}"` : 'Neuer Ordner'} - -
- setNewFolderName(e.target.value)} - placeholder="Ordnername" - className="input-field w-full px-2 py-1 text-xs" - /> -
- - -
-
-
+ setNewFolderName(e.target.value)} + placeholder="Ordnername" + className="input-field w-full h-11 sm:h-8 px-2.5 text-base sm:text-xs" + /> + )} {/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */} diff --git a/frontend/src/index.css b/frontend/src/index.css index e55e25e..b2b17b5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; } } diff --git a/frontend/src/lib/__tests__/useKeyboardInset.test.tsx b/frontend/src/lib/__tests__/useKeyboardInset.test.tsx new file mode 100644 index 0000000..b29de15 --- /dev/null +++ b/frontend/src/lib/__tests__/useKeyboardInset.test.tsx @@ -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 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); + }); +}); diff --git a/frontend/src/lib/useKeyboardInset.ts b/frontend/src/lib/useKeyboardInset.ts new file mode 100644 index 0000000..074f5f6 --- /dev/null +++ b/frontend/src/lib/useKeyboardInset.ts @@ -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; +}