bottom color on modal

This commit is contained in:
Pavel committed 2026-09-27 12:37:41 +04:00
1 parent 4e6f1bf9e4
commit 4e03bc0388
5 files changed
+128

No files matched your search

@@ -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 };
+42
View File
@@ -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<HTMLElement>('[data-slot="bottom-tint-reset"]');
const renderModal = (open: boolean) =>
render(
<Modal
open={open}
onOpenChange={() => undefined}
title="Upload"
mobileVariant="sheet"
>
Body
</Modal>,
);
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();
});
});
+2
View File
@@ -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<HTMLDivElement, ModalProps>(function Modal(
),
)}
>
{isMobileSheet && <BottomTintReset />}
{isMobileSheet && <SheetHandle />}
{headerNode}
<div
+43
View File
@@ -216,3 +216,46 @@ describe('SheetContent focus', () => {
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<HTMLElement>('[data-slot="bottom-tint-reset"]');
const renderSheet = (open: boolean, side: 'bottom' | 'right') =>
render(
<Sheet open={open}>
<SheetContent side={side} title="Tools" aria-describedby={undefined} />
</Sheet>,
);
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();
});
});
+2
View File
@@ -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 ? <SheetTitle className="sr-only">{title}</SheetTitle> : null}
{side === 'bottom' && <BottomTintReset />}
{handle && <SheetHandle />}
{children}
{showCloseButton && (