Skip to content

Commit e568e9c

Browse files
committed
fix: measure toast height and swipe deltas in layout px under CSS zoom
getBoundingClientRect() and pointer coordinates report on-screen px, but --initial-height, --offset and --swipe-amount-* are consumed as CSS lengths inside the same zoomed subtree. Under an ancestor zoom the two spaces differ by the zoom factor, so an expanded stack spaces itself out or overlaps and a swipe tracks faster or slower than the pointer. Divide by Element.currentCSSZoom, which is the effective zoom of the whole ancestor chain, so it needs no wiring from the consumer.
1 parent 8e4662b commit e568e9c

3 files changed

Lines changed: 47 additions & 6 deletions

File tree

src/index.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,19 @@ function cn(...classes: (string | undefined)[]) {
4646
return classes.filter(Boolean).join(' ');
4747
}
4848

49+
/**
50+
* Effective CSS `zoom` on a node, as the product of every zoom in its ancestor chain.
51+
*
52+
* Under an ancestor `zoom`, `getBoundingClientRect()` and pointer coordinates come back in
53+
* on-screen px, while the custom properties they feed (`--initial-height`, `--offset`,
54+
* `--swipe-amount-*`) are consumed as layout px inside that same zoomed subtree. Dividing by
55+
* this puts both in one space.
56+
*/
57+
function getCssZoom(node: HTMLElement | null): number {
58+
// `currentCSSZoom` is newer than this package's TypeScript DOM lib.
59+
return (node as (HTMLElement & { currentCSSZoom?: number }) | null)?.currentCSSZoom || 1;
60+
}
61+
4962
function getDefaultSwipeDirections(position: string): Array<SwipeDirection> {
5063
const [y, x] = position.split('-');
5164
const directions: Array<SwipeDirection> = [];
@@ -160,7 +173,7 @@ const Toast = (props: ToastProps) => {
160173
React.useEffect(() => {
161174
const toastNode = toastRef.current;
162175
if (toastNode) {
163-
const height = toastNode.getBoundingClientRect().height;
176+
const height = toastNode.getBoundingClientRect().height / getCssZoom(toastNode);
164177
// Add toast height to heights array after the toast is mounted
165178
setInitialHeight(height);
166179
setHeights((h) => [{ toastId: toast.id, height, position: toast.position }, ...h]);
@@ -174,7 +187,7 @@ const Toast = (props: ToastProps) => {
174187
const toastNode = toastRef.current;
175188
const originalHeight = toastNode.style.height;
176189
toastNode.style.height = 'auto';
177-
const newHeight = toastNode.getBoundingClientRect().height;
190+
const newHeight = toastNode.getBoundingClientRect().height / getCssZoom(toastNode);
178191
toastNode.style.height = originalHeight;
179192

180193
setInitialHeight(newHeight);
@@ -374,8 +387,9 @@ const Toast = (props: ToastProps) => {
374387
const isHighlighted = window.getSelection()?.toString().length > 0;
375388
if (isHighlighted) return;
376389

377-
const yDelta = event.clientY - pointerStartRef.current.y;
378-
const xDelta = event.clientX - pointerStartRef.current.x;
390+
const pointerZoom = getCssZoom(toastRef.current);
391+
const yDelta = (event.clientY - pointerStartRef.current.y) / pointerZoom;
392+
const xDelta = (event.clientX - pointerStartRef.current.x) / pointerZoom;
379393

380394
// Determine swipe direction if not already locked
381395
if (!swipeDirection && (Math.abs(xDelta) > 1 || Math.abs(yDelta) > 1)) {

test/src/app/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export default function Home({ searchParams }: any) {
2525
const [historySize, setHistorySize] = React.useState<number | null>(null);
2626

2727
return (
28-
<>
28+
<div style={searchParams.zoom ? { zoom: searchParams.zoom } : undefined}>
2929
{searchParams.toastOnMount === '' ? <ToastOnMount /> : null}
3030
<button data-testid="theme-button" className="button" onClick={() => setTheme('dark')}>
3131
Change theme
@@ -518,7 +518,7 @@ export default function Home({ searchParams }: any) {
518518
className: 'secondary-toaster',
519519
}}
520520
/>
521-
</>
521+
</div>
522522
);
523523
}
524524

test/tests/css-zoom.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
// `zoom` scales what getBoundingClientRect() and pointer events report, but not offsetHeight,
4+
// so a toast measured with the rect and spent on a CSS length is wrong by the zoom factor.
5+
const ZOOMS = ['0.5', '1', '2'];
6+
7+
test.describe('Ancestor CSS zoom', () => {
8+
for (const zoom of ZOOMS) {
9+
test(`stores the toast height in layout px at zoom ${zoom}`, async ({ page }) => {
10+
await page.goto(`/?zoom=${zoom}`);
11+
await page.getByTestId('infinity-toast').click();
12+
13+
const toast = page.locator('[data-sonner-toast]').first();
14+
await expect(toast).toHaveCount(1);
15+
16+
const measured = await toast.evaluate((element: HTMLElement) => ({
17+
initialHeight: Number.parseFloat(element.style.getPropertyValue('--initial-height')),
18+
offsetHeight: element.offsetHeight,
19+
}));
20+
21+
// --initial-height is consumed as a CSS length inside the zoomed subtree, so it has to
22+
// agree with offsetHeight. Before the fix it was offsetHeight * zoom, which is what the
23+
// stack then spends on --offset and translateY.
24+
expect(Math.abs(measured.initialHeight - measured.offsetHeight)).toBeLessThanOrEqual(1);
25+
});
26+
}
27+
});

0 commit comments

Comments
 (0)