diff --git a/docs/design.md b/docs/design.md index 6fe71191d..1b9aac6e3 100644 --- a/docs/design.md +++ b/docs/design.md @@ -94,6 +94,8 @@ Five primitives. The pick is determined by option count, the need to search, and `` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these. +`` owns compact bottom safe-area padding inside the sheet so the sheet background still reaches the screen bottom. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container. + `confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work. Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value is `AdaptiveModalSheet`. "Are you sure?" is `confirmDialog`. diff --git a/packages/app/src/components/adaptive-modal-sheet-layout.test.ts b/packages/app/src/components/adaptive-modal-sheet-layout.test.ts new file mode 100644 index 000000000..7fc2ba342 --- /dev/null +++ b/packages/app/src/components/adaptive-modal-sheet-layout.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout"; + +describe("getCompactSheetSafeAreaPadding", () => { + it("adds the bottom inset to compact sheet footers", () => { + expect( + getCompactSheetSafeAreaPadding({ + isCompact: true, + hasFooter: true, + baseContentPadding: 24, + baseFooterPadding: 12, + safeAreaBottom: 34, + }), + ).toEqual({ footerPaddingBottom: 46 }); + }); + + it("adds the bottom inset to compact sheet content when there is no footer", () => { + expect( + getCompactSheetSafeAreaPadding({ + isCompact: true, + hasFooter: false, + baseContentPadding: 24, + baseFooterPadding: 12, + safeAreaBottom: 34, + }), + ).toEqual({ contentPaddingBottom: 58 }); + }); + + it("does not inset desktop sheets", () => { + expect( + getCompactSheetSafeAreaPadding({ + isCompact: false, + hasFooter: false, + baseContentPadding: 24, + baseFooterPadding: 12, + safeAreaBottom: 34, + }), + ).toEqual({}); + }); +}); diff --git a/packages/app/src/components/adaptive-modal-sheet-layout.ts b/packages/app/src/components/adaptive-modal-sheet-layout.ts new file mode 100644 index 000000000..faeae9101 --- /dev/null +++ b/packages/app/src/components/adaptive-modal-sheet-layout.ts @@ -0,0 +1,30 @@ +export interface CompactSheetSafeAreaPaddingInput { + isCompact: boolean; + hasFooter: boolean; + baseContentPadding: number; + baseFooterPadding: number; + safeAreaBottom: number; +} + +export interface CompactSheetSafeAreaPadding { + contentPaddingBottom?: number; + footerPaddingBottom?: number; +} + +export function getCompactSheetSafeAreaPadding({ + isCompact, + hasFooter, + baseContentPadding, + baseFooterPadding, + safeAreaBottom, +}: CompactSheetSafeAreaPaddingInput): CompactSheetSafeAreaPadding { + if (!isCompact || safeAreaBottom <= 0) { + return {}; + } + + if (hasFooter) { + return { footerPaddingBottom: baseFooterPadding + safeAreaBottom }; + } + + return { contentPaddingBottom: baseContentPadding + safeAreaBottom }; +} diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index d27993080..5b9e85d76 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -19,6 +19,7 @@ import { IsolatedBottomSheetModal, useIsolatedBottomSheetVisibility, } from "@/components/ui/isolated-bottom-sheet-modal"; +import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout"; import { isNative, isWeb } from "@/constants/platform"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -461,9 +462,43 @@ export function AdaptiveModalSheet({ const isMobile = useIsCompactFormFactor(); const insets = useSafeAreaInsets(); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); + const compactSafeAreaPadding = useMemo( + () => + getCompactSheetSafeAreaPadding({ + isCompact: isMobile, + hasFooter: Boolean(footer), + baseContentPadding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], + baseFooterPadding: theme.spacing[3], + safeAreaBottom: insets.bottom, + }), + [footer, insets.bottom, isMobile, theme.spacing], + ); + const bottomSheetContentStyle = useMemo( + () => [ + styles.bottomSheetContent, + compactSafeAreaPadding.contentPaddingBottom != null + ? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom } + : null, + ], + [compactSafeAreaPadding.contentPaddingBottom], + ); + const bottomSheetStaticContentStyle = useMemo( + () => [ + styles.bottomSheetStaticContent, + compactSafeAreaPadding.contentPaddingBottom != null + ? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom } + : null, + ], + [compactSafeAreaPadding.contentPaddingBottom], + ); const footerStyle = useMemo( - () => [styles.footer, isMobile ? { paddingBottom: theme.spacing[3] + insets.bottom } : null], - [insets.bottom, isMobile, theme.spacing], + () => [ + styles.footer, + compactSafeAreaPadding.footerPaddingBottom != null + ? { paddingBottom: compactSafeAreaPadding.footerPaddingBottom } + : null, + ], + [compactSafeAreaPadding.footerPaddingBottom], ); const handleIndicatorStyle = useMemo( () => ({ backgroundColor: theme.colors.surface2 }), @@ -512,14 +547,14 @@ export function AdaptiveModalSheet({ {scrollable ? ( {children} ) : ( - {children} + {children} )} {footer ? {footer} : null} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 235520c40..ac6cc23ac 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -143,7 +143,7 @@ const DEFAULT_MAX_INPUT_HEIGHT = 160; const MAX_INPUT_VIEWPORT_RATIO = 0.5; const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE; const ATTACHMENT_SHEET_HEADER: SheetHeader = { title: "Add attachment" }; -const ATTACHMENT_SHEET_SNAP_POINTS = ["28%", "45%"]; +const ATTACHMENT_SHEET_SNAP_POINTS = ["34%", "45%"]; type WebTextInputKeyPressEvent = NativeSyntheticEvent< TextInputKeyPressEventData & {