Skip to content

Commit ceed5b6

Browse files
authored
feat(browser): support toBeInViewport utility method to assert element is in viewport or not (#8234)
1 parent e85e396 commit ceed5b6

File tree

5 files changed

+201
-0
lines changed

5 files changed

+201
-0
lines changed

docs/guide/browser/assertion-api.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,27 @@ await expect.element(
300300
).toBeVisible()
301301
```
302302

303+
## toBeInViewport
304+
305+
```ts
306+
function toBeInViewport(options: { ratio?: number }): Promise<void>
307+
```
308+
309+
This allows you to check if an element is currently in viewport with [IntersectionObserver API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API).
310+
311+
You can pass `ratio` argument as option, which means the minimal ratio of the element should be in viewport. `ratio` should be in 0~1.
312+
313+
```ts
314+
// A specific element is in viewport.
315+
await expect.element(page.getByText('Welcome')).toBeInViewport()
316+
317+
// 50% of a specific element should be in viewport
318+
await expect.element(page.getByText('To')).toBeInViewport({ ratio: 0.5 })
319+
320+
// Full of a specific element should be in viewport
321+
await expect.element(page.getByText('Vitest')).toBeInViewport({ ratio: 1 })
322+
```
323+
303324
## toContainElement
304325

305326
```ts

packages/browser/jest-dom.d.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,52 @@ export interface TestingLibraryMatchers<E, R> {
1414
* @see https://vitest.dev/guide/browser/assertion-api#tobeinthedocument
1515
*/
1616
toBeInTheDocument(): R
17+
/**
18+
* @description
19+
* Assert whether an element is within the viewport or not.
20+
*
21+
* An element is considered to be in the viewport if any part of it intersects with the current viewport bounds.
22+
* This matcher calculates the intersection ratio between the element and the viewport, similar to the
23+
* IntersectionObserver API.
24+
*
25+
* The element must be in the document and have visible dimensions. Elements with display: none or
26+
* visibility: hidden are considered not in viewport.
27+
* @example
28+
* <div
29+
* data-testid="visible-element"
30+
* style="position: absolute; top: 10px; left: 10px; width: 50px; height: 50px;"
31+
* >
32+
* Visible Element
33+
* </div>
34+
*
35+
* <div
36+
* data-testid="hidden-element"
37+
* style="position: fixed; top: -100px; left: 10px; width: 50px; height: 50px;"
38+
* >
39+
* Hidden Element
40+
* </div>
41+
*
42+
* <div
43+
* data-testid="large-element"
44+
* style="height: 400vh;"
45+
* >
46+
* Large Element
47+
* </div>
48+
*
49+
* // Check if any part of element is in viewport
50+
* await expect.element(page.getByTestId('visible-element')).toBeInViewport()
51+
*
52+
* // Check if element is outside viewport
53+
* await expect.element(page.getByTestId('hidden-element')).not.toBeInViewport()
54+
*
55+
* // Check if at least 50% of element is visible
56+
* await expect.element(page.getByTestId('large-element')).toBeInViewport({ ratio: 0.5 })
57+
*
58+
* // Check if element is completely visible
59+
* await expect.element(page.getByTestId('visible-element')).toBeInViewport({ ratio: 1 })
60+
* @see https://vitest.dev/guide/browser/assertion-api#tobeinviewport
61+
*/
62+
toBeInViewport(options?: { ratio?: number }): R
1763
/**
1864
* @description
1965
* This allows you to check if an element is currently visible to the user.

packages/browser/src/client/tester/expect/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import toBeEmptyDOMElement from './toBeEmptyDOMElement'
44
import { toBeDisabled, toBeEnabled } from './toBeEnabled'
55
import toBeInTheDocument from './toBeInTheDocument'
66
import { toBeInvalid, toBeValid } from './toBeInvalid'
7+
import toBeInViewport from './toBeInViewport'
78
import toBePartiallyChecked from './toBePartiallyChecked'
89
import toBeRequired from './toBeRequired'
910
import toBeVisible from './toBeVisible'
@@ -28,6 +29,7 @@ export const matchers: MatchersObject = {
2829
toBeEnabled,
2930
toBeEmptyDOMElement,
3031
toBeInTheDocument,
32+
toBeInViewport,
3133
toBeInvalid,
3234
toBeRequired,
3335
toBeValid,
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* The MIT License (MIT)
3+
* Copyright (c) 2017 Kent C. Dodds
4+
*
5+
* Permission is hereby granted, free of charge, to any person obtaining a copy
6+
* of this software and associated documentation files (the "Software"), to deal
7+
* in the Software without restriction, including without limitation the rights
8+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
* copies of the Software, and to permit persons to whom the Software is
10+
* furnished to do so, subject to the following conditions:
11+
*
12+
* The above copyright notice and this permission notice shall be included in all
13+
* copies or substantial portions of the Software.
14+
*/
15+
16+
import type { ExpectationResult, MatcherState } from '@vitest/expect'
17+
import type { Locator } from '../locators'
18+
import { getElementFromUserInput } from './utils'
19+
20+
export default function toBeInViewport(
21+
this: MatcherState,
22+
actual: Element | Locator,
23+
options?: { ratio?: number },
24+
): ExpectationResult {
25+
const htmlElement = getElementFromUserInput(actual, toBeInViewport, this)
26+
27+
const expectedRatio = options?.ratio ?? 0
28+
return getViewportIntersection(htmlElement, expectedRatio).then(({ pass, ratio }) => {
29+
return {
30+
pass,
31+
message: () => {
32+
const is = pass ? 'is' : 'is not'
33+
const ratioText = expectedRatio > 0 ? ` with ratio ${expectedRatio}` : ''
34+
const actualRatioText = ratio !== undefined ? ` (actual ratio: ${ratio.toFixed(3)})` : ''
35+
return [
36+
this.utils.matcherHint(
37+
`${this.isNot ? '.not' : ''}.toBeInViewport`,
38+
'element',
39+
'',
40+
),
41+
'',
42+
`Received element ${is} in viewport${ratioText}${actualRatioText}:`,
43+
` ${this.utils.printReceived(htmlElement.cloneNode(false))}`,
44+
].join('\n')
45+
},
46+
}
47+
})
48+
}
49+
50+
/**
51+
* Get viewport intersection ratio using IntersectionObserver API
52+
* This implementation follows Playwright's approach using IntersectionObserver as the primary mechanism
53+
*/
54+
async function getViewportIntersection(element: HTMLElement | SVGElement, expectedRatio: number): Promise<{ pass: boolean; ratio?: number }> {
55+
// Use IntersectionObserver API to get the intersection ratio
56+
// Following Playwright's exact pattern from viewportRatio function
57+
const intersectionRatio = await new Promise<number>((resolve) => {
58+
// This mimics Playwright's Promise-based implementation in a synchronous context
59+
const observer = new IntersectionObserver((entries) => {
60+
if (entries.length > 0) {
61+
resolve(entries[0].intersectionRatio)
62+
}
63+
else {
64+
resolve(0)
65+
}
66+
observer.disconnect()
67+
})
68+
69+
observer.observe(element)
70+
71+
// Firefox workaround: requestAnimationFrame to ensure observer callback fires
72+
// This is exactly how Playwright handles it
73+
requestAnimationFrame(() => {})
74+
})
75+
76+
// Apply the same logic as Playwright:
77+
// ratio > 0 && ratio > (expectedRatio - 1e-9)
78+
const pass = intersectionRatio > 0 && intersectionRatio > (expectedRatio - 1e-9)
79+
80+
return { pass, ratio: intersectionRatio }
81+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { render } from './utils'
3+
4+
describe('toBeInViewport', () => {
5+
it('should work', async () => {
6+
// Apply margin and width to small element, and padding to body and html because firefox's rendering causes a bit space between the element and the viewport
7+
const { container } = render(`
8+
<style>body, html { padding: 30px; }</style>
9+
<div id="big" style="height: 10000px;"></div>
10+
<div id="small" style="width: 50px; margin: 30px;">foo</div>
11+
`)
12+
13+
await expect(container.querySelector('#big')).toBeInViewport()
14+
await expect(container.querySelector('#small')).not.toBeInViewport()
15+
16+
// Scroll to make small element visible
17+
container.querySelector('#small')?.scrollIntoView()
18+
await expect(container.querySelector('#small')).toBeInViewport()
19+
await expect(container.querySelector('#small')).toBeInViewport({ ratio: 1 })
20+
})
21+
22+
it('should respect ratio option', async () => {
23+
const { container } = render(`
24+
<style>body, div, html { padding: 0; margin: 0; }</style>
25+
<div id="big" style="height: 400vh;"></div>
26+
`)
27+
28+
await expect(container.querySelector('div')).toBeInViewport()
29+
await expect(container.querySelector('div')).toBeInViewport({ ratio: 0.1 })
30+
await expect(container.querySelector('div')).toBeInViewport({ ratio: 0.2 })
31+
await expect(container.querySelector('div')).toBeInViewport({ ratio: 0.24 })
32+
33+
// In this test, element's ratio is approximately 0.25 (viewport height / element height = 100vh / 400vh = 0.25)
34+
// IntersectionObserver may return slightly different values due to browser rendering
35+
await expect(container.querySelector('div')).toBeInViewport({ ratio: 0.24 })
36+
await expect(container.querySelector('div')).not.toBeInViewport({ ratio: 0.26 })
37+
38+
await expect(container.querySelector('div')).not.toBeInViewport({ ratio: 0.3 })
39+
await expect(container.querySelector('div')).not.toBeInViewport({ ratio: 0.7 })
40+
await expect(container.querySelector('div')).not.toBeInViewport({ ratio: 0.8 })
41+
})
42+
43+
it('should report intersection even if fully covered by other element', async () => {
44+
const { container } = render(`
45+
<h1>hello</h1>
46+
<div style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: red; z-index: 1000;"></div>
47+
`)
48+
49+
await expect(container.querySelector('h1')).toBeInViewport()
50+
})
51+
})

0 commit comments

Comments
 (0)