mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 06:02:56 +00:00
fix(app): keep the centered new workspace form above the keyboard on tablets (#6093)
* fix(app): keep the centered new workspace form usable with the keyboard open The centered tablet dock translated the whole form by the full keyboard shift and reserved the keyboard twice to keep its top below the header. On short tablet viewports such as an unfolded foldable, that left about 20pt for the setup fields and composer, which then overlapped. Translate the centered form only by the part of the keyboard that reaches it, and size it to fit above the keyboard. Fixes #6012 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(app): rest the centered new workspace form on the keyboard The keyboard shift excludes the bottom safe area, but the keyboard also covers it. Measuring the centered form's clearance and capacity down to the screen edge left the form one navigation-bar height under the keyboard, hiding Import session on Android tablets. Measure both above the bottom safe area so the form ends at the keyboard's top edge. Fixes #6012 Co-authored-by: Ivan Malison <IvanMalison@gmail.com> --------- Co-authored-by: Ivan Malison <IvanMalison@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Ivan Malison
Claude Opus 5.5
parent
7b43f21940
commit
a50e14b69e
@@ -5,7 +5,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings";
|
||||
import { KeyboardTranslateView } from "@/keyboard/shift";
|
||||
import { createContext, useCallback, useContext, type ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
@@ -15,7 +15,11 @@ import Animated, {
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useKeyboardShift } from "@/keyboard/shift";
|
||||
import { updateComposerCapacity, type ComposerCapacity } from "./internal/capacity";
|
||||
import {
|
||||
resolveCenteredClearance,
|
||||
updateComposerCapacity,
|
||||
type ComposerCapacity,
|
||||
} from "./internal/capacity";
|
||||
|
||||
const ViewportCapacity = createContext<SharedValue<number | undefined> | null>(null);
|
||||
|
||||
@@ -34,11 +38,12 @@ function ComposerViewport({
|
||||
}: ComposerViewportProps) {
|
||||
const measuredHeight = useSharedValue(0);
|
||||
const sizing = useSharedValue<ComposerCapacity | undefined>(undefined);
|
||||
const { layoutShift } = useKeyboardShift();
|
||||
const { layoutShift, bottomInset: safeAreaBottom } = useKeyboardShift();
|
||||
useAnimatedReaction(
|
||||
() => ({
|
||||
height: measuredHeight.value,
|
||||
bottomInset,
|
||||
safeAreaBottom: safeAreaBottom.value,
|
||||
keyboardShift: layoutShift.value,
|
||||
centered,
|
||||
}),
|
||||
@@ -79,6 +84,24 @@ function ComposerViewportContent({ style, ...props }: ViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The keyboard only needs to move a centered form once it reaches the form's resting bottom. */
|
||||
function useCenteredClearance(safeAreaBottom: number) {
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
const [formBottom, setFormBottom] = useState(0);
|
||||
const measureViewport = useCallback((event: LayoutChangeEvent) => {
|
||||
setViewportHeight(event.nativeEvent.layout.height);
|
||||
}, []);
|
||||
const measureForm = useCallback((event: LayoutChangeEvent) => {
|
||||
const { y, height } = event.nativeEvent.layout;
|
||||
setFormBottom(y + height);
|
||||
}, []);
|
||||
return {
|
||||
value: resolveCenteredClearance({ viewportHeight, safeAreaBottom, formBottom }),
|
||||
measureViewport,
|
||||
measureForm,
|
||||
};
|
||||
}
|
||||
|
||||
interface ComposerDockProps {
|
||||
children: [ReactNode, ReactNode, ReactNode?];
|
||||
centered?: boolean;
|
||||
@@ -91,6 +114,7 @@ export function ComposerDock({
|
||||
}: ComposerDockProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings);
|
||||
const centeredClearance = useCenteredClearance(insets.bottom);
|
||||
// Preserve the existing centered form's visual balance on tablets.
|
||||
const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0;
|
||||
if (centered) {
|
||||
@@ -99,8 +123,13 @@ export function ComposerDock({
|
||||
style={[dockStyles.centeredViewport, { paddingBottom: bottomInset }]}
|
||||
bottomInset={bottomInset}
|
||||
centered
|
||||
onLayout={centeredClearance.measureViewport}
|
||||
>
|
||||
<KeyboardTranslateView style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}>
|
||||
<KeyboardTranslateView
|
||||
style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}
|
||||
bottomClearance={centeredClearance.value}
|
||||
onLayout={centeredClearance.measureForm}
|
||||
>
|
||||
<ComposerViewportContent style={dockStyles.composer}>
|
||||
<ScrollView style={dockStyles.setup} keyboardShouldPersistTaps="handled">
|
||||
{content}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveComposerCapacity, updateComposerCapacity } from "./capacity";
|
||||
import {
|
||||
resolveCenteredClearance,
|
||||
resolveComposerCapacity,
|
||||
updateComposerCapacity,
|
||||
} from "./capacity";
|
||||
|
||||
describe("composer viewport", () => {
|
||||
it("preserves the editing capacity when the keyboard closes", () => {
|
||||
const viewport = { height: 582, bottomInset: 24, centered: false };
|
||||
const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false };
|
||||
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
|
||||
const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 });
|
||||
expect(open.capacity).toBe(245);
|
||||
@@ -15,6 +19,7 @@ describe("composer viewport", () => {
|
||||
const open = updateComposerCapacity(undefined, {
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
centered: false,
|
||||
keyboardShift: 308,
|
||||
});
|
||||
@@ -22,6 +27,7 @@ describe("composer viewport", () => {
|
||||
updateComposerCapacity(open, {
|
||||
height: 650,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
centered: false,
|
||||
keyboardShift: 0,
|
||||
}).capacity,
|
||||
@@ -30,6 +36,7 @@ describe("composer viewport", () => {
|
||||
updateComposerCapacity(open, {
|
||||
height: 0,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
centered: false,
|
||||
keyboardShift: 0,
|
||||
}),
|
||||
@@ -39,6 +46,7 @@ describe("composer viewport", () => {
|
||||
const height = resolveComposerCapacity({
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 308,
|
||||
centered: false,
|
||||
});
|
||||
@@ -46,23 +54,80 @@ describe("composer viewport", () => {
|
||||
expect(582 - 24 - 308 - height).toBe(5);
|
||||
});
|
||||
|
||||
it("keeps a centered tablet form below the header after translation", () => {
|
||||
it("keeps a centered tablet form below the header once the keyboard reaches it", () => {
|
||||
const viewport = 1000;
|
||||
const bottomInset = 80;
|
||||
const keyboardShift = 300;
|
||||
const height = resolveComposerCapacity({
|
||||
height: 1000,
|
||||
bottomInset: 80,
|
||||
keyboardShift: 300,
|
||||
height: viewport,
|
||||
bottomInset,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift,
|
||||
centered: true,
|
||||
});
|
||||
expect(height).toBe(310);
|
||||
expect((1000 - 80 - height) / 2 - 300).toBe(5);
|
||||
expect(height).toBe(695);
|
||||
const restingTop = (viewport - bottomInset - height) / 2;
|
||||
const bottomClearance = resolveCenteredClearance({
|
||||
viewportHeight: viewport,
|
||||
safeAreaBottom: 0,
|
||||
formBottom: restingTop + height,
|
||||
});
|
||||
expect(restingTop - Math.max(0, keyboardShift - bottomClearance)).toBe(5);
|
||||
});
|
||||
|
||||
it("leaves a centered form room to edit on a short foldable viewport", () => {
|
||||
expect(
|
||||
resolveComposerCapacity({
|
||||
height: 725,
|
||||
bottomInset: 72,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 313,
|
||||
centered: true,
|
||||
}),
|
||||
).toBe(407);
|
||||
});
|
||||
|
||||
it("rests a centered form on the keyboard above the bottom safe area", () => {
|
||||
// The keyboard shift excludes the safe area that the keyboard also covers.
|
||||
const geometry = {
|
||||
height: 660,
|
||||
bottomInset: 60,
|
||||
safeAreaBottom: 32,
|
||||
keyboardShift: 288,
|
||||
centered: true,
|
||||
};
|
||||
const height = resolveComposerCapacity(geometry);
|
||||
const restingTop = (geometry.height - geometry.bottomInset - height) / 2;
|
||||
const formBottom = restingTop + height;
|
||||
const clearance = resolveCenteredClearance({
|
||||
viewportHeight: geometry.height,
|
||||
safeAreaBottom: geometry.safeAreaBottom,
|
||||
formBottom,
|
||||
});
|
||||
const translation = Math.max(0, geometry.keyboardShift - clearance);
|
||||
const keyboardTop = geometry.height - geometry.safeAreaBottom - geometry.keyboardShift;
|
||||
expect(formBottom - translation).toBe(keyboardTop);
|
||||
expect(restingTop - translation).toBe(5);
|
||||
});
|
||||
|
||||
it("uses the measured viewport before the first keyboard opening", () => {
|
||||
expect(
|
||||
resolveComposerCapacity({ height: 582, bottomInset: 24, keyboardShift: 0, centered: false }),
|
||||
resolveComposerCapacity({
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 0,
|
||||
centered: false,
|
||||
}),
|
||||
).toBe(553);
|
||||
expect(
|
||||
resolveComposerCapacity({ height: 300, bottomInset: 0, keyboardShift: 200, centered: false }),
|
||||
resolveComposerCapacity({
|
||||
height: 300,
|
||||
bottomInset: 0,
|
||||
safeAreaBottom: 0,
|
||||
keyboardShift: 200,
|
||||
centered: false,
|
||||
}),
|
||||
).toBe(95);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
interface ComposerGeometry {
|
||||
height: number;
|
||||
bottomInset: number;
|
||||
/** The keyboard also covers this space below its shift. */
|
||||
safeAreaBottom: number;
|
||||
keyboardShift: number;
|
||||
centered: boolean;
|
||||
}
|
||||
@@ -12,11 +14,25 @@ export interface ComposerCapacity {
|
||||
|
||||
export function resolveComposerCapacity(input: ComposerGeometry): number {
|
||||
"worklet";
|
||||
// A centered form grows upward by half its height. Reserve both halves so
|
||||
// translating it still leaves five layout points below the header.
|
||||
const clearance = input.keyboardShift + 5;
|
||||
const reservedSpace = input.centered ? clearance * 2 : clearance;
|
||||
return Math.max(0, input.height - input.bottomInset - reservedSpace);
|
||||
// A centered form only translates by the keyboard overlap, so once the keyboard
|
||||
// reaches it the form sits on the keyboard and fits above it, not twice over.
|
||||
// A bottom-anchored composer pads the safe area inside its own capacity.
|
||||
const available = input.centered
|
||||
? Math.min(
|
||||
input.height - input.bottomInset,
|
||||
input.height - input.safeAreaBottom - input.keyboardShift,
|
||||
)
|
||||
: input.height - input.bottomInset - input.keyboardShift;
|
||||
return Math.max(0, available - 5);
|
||||
}
|
||||
|
||||
/** Space below a centered form that the keyboard fills before the form moves. */
|
||||
export function resolveCenteredClearance(input: {
|
||||
viewportHeight: number;
|
||||
safeAreaBottom: number;
|
||||
formBottom: number;
|
||||
}): number {
|
||||
return Math.max(0, input.viewportHeight - input.safeAreaBottom - input.formBottom);
|
||||
}
|
||||
|
||||
export function updateComposerCapacity(
|
||||
|
||||
@@ -6,17 +6,20 @@ import { useKeyboardShift } from "./context";
|
||||
interface KeyboardTranslateViewProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
/** Space below the view's resting position that the keyboard may fill before the view moves. */
|
||||
bottomClearance?: number;
|
||||
}
|
||||
|
||||
export function KeyboardTranslateView({
|
||||
children,
|
||||
enabled = true,
|
||||
bottomClearance = 0,
|
||||
style,
|
||||
...props
|
||||
}: KeyboardTranslateViewProps) {
|
||||
const { shift } = useKeyboardShift();
|
||||
const keyboardStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: enabled ? -shift.value : 0 }],
|
||||
transform: [{ translateY: enabled ? -Math.max(0, shift.value - bottomClearance) : 0 }],
|
||||
}));
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,19 +6,31 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
interface KeyboardTranslateViewProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
/** Space below the view's resting position that the keyboard may fill before the view moves. */
|
||||
bottomClearance?: number;
|
||||
}
|
||||
|
||||
export function KeyboardTranslateView({
|
||||
children,
|
||||
enabled = true,
|
||||
bottomClearance = 0,
|
||||
style,
|
||||
...props
|
||||
}: KeyboardTranslateViewProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { height, progress } = useKeyboardAnimation();
|
||||
const translateY = useMemo(
|
||||
() => Animated.add(height, Animated.multiply(progress, insets.bottom)),
|
||||
[height, insets.bottom, progress],
|
||||
() =>
|
||||
Animated.add(
|
||||
Animated.add(height, Animated.multiply(progress, insets.bottom)),
|
||||
bottomClearance,
|
||||
).interpolate({
|
||||
inputRange: [-1, 0],
|
||||
outputRange: [-1, 0],
|
||||
extrapolateLeft: "extend",
|
||||
extrapolateRight: "clamp",
|
||||
}),
|
||||
[bottomClearance, height, insets.bottom, progress],
|
||||
);
|
||||
const keyboardStyle = useMemo(
|
||||
() => ({ transform: [{ translateY: enabled ? translateY : 0 }] }),
|
||||
|
||||
@@ -4,11 +4,13 @@ import { View, type ViewProps } from "react-native";
|
||||
interface KeyboardTranslateViewProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
bottomClearance?: number;
|
||||
}
|
||||
|
||||
export function KeyboardTranslateView({
|
||||
children,
|
||||
enabled: _enabled,
|
||||
bottomClearance: _bottomClearance,
|
||||
...props
|
||||
}: KeyboardTranslateViewProps) {
|
||||
return <View {...props}>{children}</View>;
|
||||
|
||||
Reference in New Issue
Block a user