From 01983a38108716616b18a78bc3d0a96a0e596054 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:44:43 +0100 Subject: [PATCH] fix: keep the event editor's bottom fields above the keyboard #81 --- src/components/calendar/EventModal.tsx | 29 +++++++++++++++++-- .../calendar-editor-keyboard.test.ts | 17 +++++++++++ src/lib/calendar-editor-keyboard.ts | 12 ++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 src/lib/__tests__/calendar-editor-keyboard.test.ts create mode 100644 src/lib/calendar-editor-keyboard.ts diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index e13f75c5..f7ebebb2 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -8,8 +8,9 @@ import { StyleSheet, Platform, Alert as NativeAlert, + type LayoutChangeEvent, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import DateTimePicker from '@react-native-community/datetimepicker'; import { X, @@ -72,6 +73,8 @@ import { useContactNameResolver } from '../../lib/contact-name-resolver'; import { RecurrenceEditor } from './RecurrenceEditor'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; import { SafeAreaModal } from '../SafeAreaModal'; +import { useKeyboardObstruction } from '../../lib/use-keyboard-obstruction'; +import { editorKeyboardInset } from '../../lib/calendar-editor-keyboard'; import ToggleSwitch from '../ToggleSwitch'; type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'; @@ -185,6 +188,25 @@ export function EventModal({ const isEdit = !!event; const resolveContactName = useContactNameResolver(); + // The keyboard covered the bottom fields (location, description; #81). + // iOS: the scroll view insets itself and scrolls the focused field into + // view (automaticallyAdjustKeyboardInsets). Android: the edge-to-edge + // dialog does not resize for the keyboard, so the scroll view ends above + // it, and Android's ScrollView brings the focused field back into view + // when it shrinks. + const insets = useSafeAreaInsets(); + const keyboard = useKeyboardObstruction(insets.bottom); + const fullHeight = React.useRef(0); + const [containerHeight, setContainerHeight] = React.useState(0); + const onContainerLayout = (e: LayoutChangeEvent) => { + const { height } = e.nativeEvent.layout; + if (keyboard === 0) fullHeight.current = height; + setContainerHeight(height); + }; + const keyboardInset = Platform.OS === 'android' + ? editorKeyboardInset(keyboard, insets.bottom, fullHeight.current - containerHeight) + : 0; + const [title, setTitle] = React.useState(''); const [description, setDescription] = React.useState(''); const [allDay, setAllDay] = React.useState(false); @@ -441,7 +463,7 @@ export function EventModal({ presentationStyle="fullScreen" onRequestClose={onClose} > - + 0 && { marginBottom: keyboardInset }]} contentContainerStyle={styles.bodyContent} keyboardShouldPersistTaps="handled" + automaticallyAdjustKeyboardInsets > { + it('ends the scroll area above the keyboard, past the safe-area inset already kept clear', () => { + expect(editorKeyboardInset(320, 24, 0)).toBe(296); + }); + + it('shortens a window that resized for the keyboard by nothing more', () => { + expect(editorKeyboardInset(320, 24, 296)).toBe(0); + expect(editorKeyboardInset(320, 24, 400)).toBe(0); + }); + + it('keeps the full height without a keyboard', () => { + expect(editorKeyboardInset(0, 24, 0)).toBe(0); + }); +}); diff --git a/src/lib/calendar-editor-keyboard.ts b/src/lib/calendar-editor-keyboard.ts new file mode 100644 index 00000000..0c60776f --- /dev/null +++ b/src/lib/calendar-editor-keyboard.ts @@ -0,0 +1,12 @@ +/** + * How far above its normal bottom the event editor's scroll area has to end + * so the keyboard covers none of it (#81). `obstruction` is how much of the + * screen bottom the keyboard covers (use-keyboard-obstruction), + * `bottomInset` the safe-area inset the editor already keeps clear, and + * `resizedBy` how much the window itself shrank for the keyboard: an + * edge-to-edge Android dialog does not resize at all, a window that does + * must not be shortened twice. + */ +export function editorKeyboardInset(obstruction: number, bottomInset: number, resizedBy: number): number { + return Math.max(0, Math.round(obstruction - bottomInset - Math.max(0, resizedBy))); +}