diff --git a/frontend/src/components/ui/bottom-tint-reset.ts b/frontend/src/components/ui/bottom-tint-reset.ts new file mode 100644 index 00000000..58ac01f7 --- /dev/null +++ b/frontend/src/components/ui/bottom-tint-reset.ts @@ -0,0 +1,39 @@ +import * as React from 'react'; + +// Tested on iPhone (Safari 26, Low Power Mode too): in the app 6px works and +// 4px doesn't (4px was enough on a bare test page, 2px never). One painted +// frame is enough; a 16ms timer isn't, because it can fire before a paint +// (Low Power Mode drops the refresh rate), so the strip lives for one frame. + +/** + * Makes iOS Safari re-colour its bottom bar from the page. + * + * Safari 26 takes the bottom bar's colour from a fixed element that appears on + * the bottom edge (a bottom sheet turns it `bg-card`) and doesn't change it + * back when that element goes away, or when an existing element changes + * colour. A page-coloured strip that appears briefly on the edge makes Safari + * sample again. The strip is 6px, no taller than the gap under the composer, + * so it covers only background of its own colour. It has to be on top: a + * strip behind the content, transparent or at `opacity-0` isn't sampled. + */ +function resetBottomTint(): void { + const strip = document.createElement('div'); + strip.dataset.slot = 'bottom-tint-reset'; + strip.setAttribute('aria-hidden', 'true'); + strip.className = + 'bg-background pointer-events-none fixed inset-x-0 bottom-0 z-50 h-1.5'; + document.body.appendChild(strip); + // The first frame paints the strip; remove it on the next. + requestAnimationFrame(() => requestAnimationFrame(() => strip.remove())); +} + +/** + * Renders nothing; resets Safari's bottom bar when it unmounts. Put it inside + * a bottom sheet's Radix Content, which unmounts after its exit animation. + */ +function BottomTintReset(): null { + React.useEffect(() => resetBottomTint, []); + return null; +} + +export { BottomTintReset, resetBottomTint }; diff --git a/frontend/src/components/ui/modal.test.tsx b/frontend/src/components/ui/modal.test.tsx index b0e611b4..ca85d0df 100644 --- a/frontend/src/components/ui/modal.test.tsx +++ b/frontend/src/components/ui/modal.test.tsx @@ -290,3 +290,45 @@ describe('Modal focus return', () => { expect(document.activeElement).toBe(document.body); }); }); + +describe('Modal bottom-bar reset', () => { + // Earlier tests close bottom sheets on unmount, which leaves strips behind. + beforeEach(() => { + document + .querySelectorAll('[data-slot="bottom-tint-reset"]') + .forEach((node) => node.remove()); + }); + + afterEach(() => { + media.isMobile = false; + }); + + const strip = () => + document.querySelector('[data-slot="bottom-tint-reset"]'); + + const renderModal = (open: boolean) => + render( + undefined} + title="Upload" + mobileVariant="sheet" + > + Body + , + ); + + it('resets the bottom bar when the phone sheet closes', async () => { + media.isMobile = true; + await renderModal(true); + await renderModal(false); + expect(strip()).not.toBeNull(); + }); + + it('leaves the bar alone for the centred dialog', async () => { + await renderModal(true); + await renderModal(false); + await settle(); + expect(strip()).toBeNull(); + }); +}); diff --git a/frontend/src/components/ui/modal.tsx b/frontend/src/components/ui/modal.tsx index 4e2e13bc..6500d4f1 100644 --- a/frontend/src/components/ui/modal.tsx +++ b/frontend/src/components/ui/modal.tsx @@ -2,6 +2,7 @@ import { XIcon } from 'lucide-react'; import { Dialog as DialogPrimitive, VisuallyHidden } from 'radix-ui'; import * as React from 'react'; +import { BottomTintReset } from '@/components/ui/bottom-tint-reset'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -138,6 +139,7 @@ const Modal = React.forwardRef(function Modal( ), )} > + {isMobileSheet && } {isMobileSheet && } {headerNode}
{ expect(document.activeElement).toBe(trigger()); }); }); + +describe('SheetContent bottom-bar reset', () => { + // Earlier tests close bottom sheets on unmount, which leaves strips behind. + beforeEach(() => { + document + .querySelectorAll('[data-slot="bottom-tint-reset"]') + .forEach((node) => node.remove()); + }); + + const wait = (ms: number) => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, ms)); + }); + const strip = () => + document.querySelector('[data-slot="bottom-tint-reset"]'); + + const renderSheet = (open: boolean, side: 'bottom' | 'right') => + render( + + + , + ); + + it('shows a 6px page-coloured strip on the bottom edge for one frame after a bottom sheet closes', async () => { + await renderSheet(true, 'bottom'); + expect(strip()).toBeNull(); + await renderSheet(false, 'bottom'); + const classes = strip()!.className.split(' '); + expect(classes).toEqual( + expect.arrayContaining(['bg-background', 'fixed', 'bottom-0', 'h-1.5']), + ); + expect(strip()!.getAttribute('aria-hidden')).toBe('true'); + await wait(120); + expect(strip()).toBeNull(); + }); + + it('adds no strip for a side sheet', async () => { + await renderSheet(true, 'right'); + await renderSheet(false, 'right'); + await wait(20); + expect(strip()).toBeNull(); + }); +}); diff --git a/frontend/src/components/ui/sheet.tsx b/frontend/src/components/ui/sheet.tsx index 04df1079..98567b66 100644 --- a/frontend/src/components/ui/sheet.tsx +++ b/frontend/src/components/ui/sheet.tsx @@ -3,6 +3,7 @@ import { Dialog as SheetPrimitive } from 'radix-ui'; import { XIcon } from 'lucide-react'; import { cva } from 'class-variance-authority'; +import { BottomTintReset } from '@/components/ui/bottom-tint-reset'; import { Button } from '@/components/ui/button'; import { cn, overlayScrim } from '@/lib/utils'; import { useFocusReturn } from '@/components/ui/use-focus-return'; @@ -121,6 +122,7 @@ function SheetContent({ {...focusReturn} > {title ? {title} : null} + {side === 'bottom' && } {handle && } {children} {showCloseButton && (