From 2f790a2d75ef65fafdf1508f4a6488d55cf5bb68 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:30:21 +0100 Subject: [PATCH] fix: keep toasts above the Save and Cancel buttons of the contact and group forms --- .../contacts/__tests__/contact-form.test.tsx | 18 ++++++++++++++++++ components/contacts/contact-form.tsx | 5 ++++- components/contacts/contact-group-form.tsx | 5 ++++- components/ui/__tests__/toast-layout.test.tsx | 7 +++++++ components/ui/toast.tsx | 5 +++-- hooks/use-bottom-controls.ts | 5 +++-- 6 files changed, 39 insertions(+), 6 deletions(-) diff --git a/components/contacts/__tests__/contact-form.test.tsx b/components/contacts/__tests__/contact-form.test.tsx index c6d304ba2..e6c6570a0 100644 --- a/components/contacts/__tests__/contact-form.test.tsx +++ b/components/contacts/__tests__/contact-form.test.tsx @@ -1,6 +1,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ContactForm } from '../contact-form'; +import { ContactGroupForm } from '../contact-group-form'; import type { ContactCard, ContactMedia } from '@/lib/jmap/types'; import { useAuthStore } from '@/stores/auth-store'; import { findContactPhotoEntry } from '@/stores/contact-store'; @@ -106,6 +107,23 @@ describe('ContactForm', () => { expect(onDirtyChange).toHaveBeenLastCalledWith(true); }); + it.each([ + ['contact form', () => ], + ['group form', () => ], + ])('keeps toasts off the %s footer by registering it as a bottom bar', (_name, form) => { + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + () => ({ left: 0, top: window.innerHeight - 72, width: 600, height: 72, right: 600, bottom: window.innerHeight, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect, + ); + try { + const { unmount } = render(form()); + expect(document.documentElement.style.getPropertyValue('--bottom-controls-height')).toBe('72px'); + unmount(); + expect(document.documentElement.style.getPropertyValue('--bottom-controls-height')).toBe(''); + } finally { + rect.mockRestore(); + } + }); + describe('photos (P0-3)', () => { // The same PHOTO as ContactCard/get returns it on each version. const photo0_16_23: ContactMedia = { kind: 'photo', uri: 'data:image/jpeg;base64,/9j/', mediaType: 'image/jpeg' }; diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index 995eb1f2d..e98603380 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -10,6 +10,7 @@ import { Avatar } from "@/components/ui/avatar"; import { findContactPhotoEntry, getContactPhotoRef, type ContactPhotoRef } from "@/stores/contact-store"; import type { ContactSaveOptions } from "@/lib/contact-media"; import { cn } from "@/lib/utils"; +import { useBottomControls } from "@/hooks/use-bottom-controls"; import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types"; interface EmailEntry { @@ -463,6 +464,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress const [initialFields] = useState(fields); const dirty = fields !== initialFields || photoEdit.kind !== "unchanged"; useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]); + const footerRef = useBottomControls(); const handlePhotoSelect = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -1194,7 +1196,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress -
+ {/* Toasts sit above Save and Cancel, not on them. */} +
diff --git a/components/contacts/contact-group-form.tsx b/components/contacts/contact-group-form.tsx index faf41feff..21389ed69 100644 --- a/components/contacts/contact-group-form.tsx +++ b/components/contacts/contact-group-form.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Avatar } from "@/components/ui/avatar"; import { cn } from "@/lib/utils"; +import { useBottomControls } from "@/hooks/use-bottom-controls"; import type { ContactCard } from "@/lib/jmap/types"; import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; @@ -44,6 +45,7 @@ export function ContactGroupForm({ const [initial] = useState(() => ({ name, members: [...selectedIds].sort().join(",") })); const dirty = name !== initial.name || [...selectedIds].sort().join(",") !== initial.members; useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]); + const footerRef = useBottomControls(); const filteredIndividuals = useMemo(() => { if (!memberSearch) return individuals; @@ -195,7 +197,8 @@ export function ContactGroupForm({ )}
-
+ {/* Toasts sit above Save and Cancel, not on them. */} +
diff --git a/components/ui/__tests__/toast-layout.test.tsx b/components/ui/__tests__/toast-layout.test.tsx index 5fc4a8cb9..5068e33a2 100644 --- a/components/ui/__tests__/toast-layout.test.tsx +++ b/components/ui/__tests__/toast-layout.test.tsx @@ -41,6 +41,13 @@ describe('toast layout', () => { expect(container).toHaveClass('[html[data-composer-open]_&]:bottom-20'); }); + it('sits above a registered bottom bar on the desktop too, such as a form footer', () => { + render(); + const container = screen.getByRole('status'); + expect(container).toHaveClass('bottom-[max(1.25rem,calc(var(--bottom-controls-height,0px)+0.75rem))]'); + expect(container).not.toHaveClass('bottom-5'); + }); + it('names the close button and hides it on a phone', () => { render(); act(() => { toast.success('Saved'); }); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index d2e1b91af..5d43a493c 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -317,8 +317,9 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose:
says how far from the bottom of the * window the topmost of them reaches, and toasts sit above it. */