How to Test Pull To Refresh on Web (Complete Guide)
Pull-to-refresh (PTR) has migrated from native mobile apps to responsive web experiences. Users expect the same gesture‑driven reload when they drag down on a list, a feed, or a dashboard. When the ge
Why Pull-to-Refresh Matters on the Web
Pull-to-refresh (PTR) has migrated from native mobile apps to responsive web experiences. Users expect the same gesture‑driven reload when they drag down on a list, a feed, or a dashboard. When the gesture works, it feels instantaneous and gives a sense of control. When it fails, the perception of the product degrades quickly: users may think the app is broken, they may abandon the flow, or they may repeatedly trigger the gesture, causing extra network load and UI thrash.
In production, PTR bugs surface as:
- Silent failures – the gesture is detected but no data request is issued, leaving stale content.
- Duplicate requests – the gesture triggers multiple fetches, leading to rate‑limit hits or inconsistent UI state.
- UI jank – the refresh indicator stays stuck, or the page jumps after the gesture, harming perceived performance.
- Accessibility gaps – screen‑reader users never receive a live region update, so they are unaware that new content arrived.
- Security oversights – a maliciously crafted pull distance can be abused to trigger unintended endpoints if validation is missing.
Because the gesture blends touch, scroll, and animation logic, it touches many layers of the front‑end stack: gesture handling, state management, networking, and UI rendering. A defect in any of those layers can manifest as a PTR problem, making it a valuable litmus test for overall UI robustness.
Understanding the Mechanics of Pull-to-Refresh
At its core, a web PTR implementation consists of three interacting pieces:
- Gesture detector – listens for touchstart, touchmove, touchend (or pointer events) and computes a vertical displacement.
- State machine – translates displacement into UI states: idle, pulling, refreshing, and reset.
- Refresh action – usually an asynchronous data fetch that, upon completion, signals the state machine to return to idle and updates the displayed list.
A typical flow looks like this:
| Phase | User action | Internal variable | Visual cue |
|---|---|---|---|
| Idle | Finger rests on screen | pullDistance = 0 | No indicator |
| Pulling | Finger moves down | pullDistance grows (clamped to max) | Indicator appears, possibly stretches |
| Triggered | pullDistance exceeds threshold on touchend | State = refreshing | Spinner shows, list locks |
| Refreshing | Network request in flight | isFetching = true | Spinner animates |
| Success | Request resolves | State = idle, pullDistance = 0 | List updates, indicator hides |
| Failure | Request rejects | State = idle, pullDistance = 0 | Error toast, indicator hides |
Implementation details vary: some libraries use CSS transforms to move a wrapper, others rely on the Intersection Observer to detect when a sentinel element crosses the viewport threshold, and a few synthesize the pull distance mathematically.
Comprehensive Test Matrix for Pull-to-Refresh
A thorough test plan covers not only the happy path but also failure modes, edge conditions, accessibility, and security considerations. The matrix below organizes scenarios by category, expected outcome, and notes on automation difficulty.
| Category | ID | Description | Preconditions | Steps | Expected Result | Automation Difficulty |
|---|---|---|---|---|---|---|
| Happy Path | HP1 | Pull distance reaches trigger threshold and releases | Page loaded, list with at least one item | Drag down until indicator shows, release | Spinner appears, new data fetched, list updates, indicator hides | Easy (e2e) |
| Happy Path | HP2 | Pull distance below threshold, no refresh | Same as HP1 | Drag down partially, release before threshold | Indicator may appear briefly, then retract; no network call | Easy |
| Happy Path | HP3 | Rapid successive pulls (double‑pull) | Same as HP1 | Pull to trigger, release, immediately pull again before reset | Second pull ignored until refresh completes; no duplicate request | Medium (needs timing control) |
| Error Path | EP1 | Network error during refresh | Mock API to return 500 | Trigger PTR | Spinner shows, error toast appears, list unchanged, indicator hides | Medium |
| Error Path | EP2 | Timeout exceeds configured limit | Mock API to delay > timeout | Trigger PTR | Spinner shows, timeout error displayed, list unchanged, indicator hides | Medium |
| Error Path | EP3 | Abort previous request on new pull | Same as EP1, but fast second pull before first resolves | Trigger PTR, then pull again before first resolves | First request aborted, second request starts, no duplicate UI state | Hard (requires request interception) |
| Edge Case | EC1 | Pull on empty list | Page with zero‑item list | Trigger PTR | Same behavior as non‑empty list; spinner shows, fetch runs, empty state updates | Easy |
| Edge Case | EC2 | Pull while modal/open dialog is visible | Dialog overlay covering scroll area | Trigger PTR | Gesture ignored or blocked; no spinner, no request | Easy |
| Edge Case | EC3 | Pull with simultaneous scroll (e.g., user flicks up while pulling) | Same as HP1 | Start pull, then quickly flick up before release | Pull distance may reset; no spurious refresh | Medium |
| Edge Case | EC4 | Pull on transformed/scrolled container (e.g., page with fixed header) | Page with sticky header pulling occurs on content below | Trigger PTR | Pull distance measured relative to scrollable area, not viewport; works as expected | Medium |
| Accessibility | A1 | Screen reader announcement of refresh start | Page with ARIA live region | Trigger PTR | Live region receives “Refreshing…” message | Medium (needs ARIA check) |
| Accessibility | A2 | Screen reader announcement of refresh completion | Same as A1 | After successful fetch | Live region receives “Updated X items” or similar | Medium |
| Accessibility | A3 | Keyboard equivalent (e.g., Ctrl+R) triggers same logic | Page focused | Press Ctrl+R | Same network request and UI updates as PTR | Easy |
| Security/Privacy | SEC1 | Pull distance validation prevents out‑of‑bounds requests | Endpoint expects numeric page parameter | Manually set pullDistance via devtools to huge value | Request either clamped or rejected; no illegal parameter sent | Hard (requires direct DOM manipulation) |
| SEC2 | Pull gesture does not leak sensitive data via URL or headers | Authenticated endpoint | Trigger PTR | Inspect network request | No auth token exposed in query string; headers follow same policy as other requests | Easy |
| SEC3 | Pull‑to‑refresh cannot be used to trigger CSRF‑prone endpoint without same‑origin checks | Endpoint accepting POST with side‑effects | Trigger PTR | Verify request method and origin | Only GET (or safe method) used, or CSRF token present | Medium |
Notes on the Matrix
- Automation Difficulty reflects the effort to reproduce the scenario reliably in CI. “Easy” means a standard e2e test can assert the outcome with minimal mocking. “Medium” often requires request interception or custom event dispatch. “Hard” may need low‑level DOM manipulation or timing tricks that are fragile.
- The matrix is deliberately exhaustive for a single component; in practice you may prioritize based on risk, but having the full set helps identify gaps when a new feature (e.g., infinite scroll) is added atop PTR.
Manual Testing Playbook
Even with automation, a manual exploratory session catches nuances that scripts miss—especially around gesture feel, visual polish, and unexpected device behaviors.
Setting Up a Test Environment
- Device selection – test on at least one physical touchscreen (phone or tablet) and one emulated touch environment (Chrome DevTools device toolbar).
- Browser matrix – Chrome, Safari, Firefox, and Edge (Chromium) because touch‑event handling differs slightly.
- Network throttling – use DevTools → Network → Throttling to simulate 3G, slow 4G, and offline conditions.
- Accessibility tools – enable VoiceOver (macOS/iOS) or TalkBack (Android) and the axe core extension for automated ARIA checks.
- Logging – add a temporary
window.ptrLog = []array and push objects{timestamp, phase, pullDistance, requestId}from your PTR code; inspect via console after each test.
Step‑by‑Step Manual Test Procedure
| Step | Action | Observation | Pass/Fail Criteria |
|---|---|---|---|
| 1 | Load the page with a populated list. | List renders, no spinner. | UI stable. |
| 2 | Place one finger on the screen, drag down ~20 px. | Indicator may appear slightly; pull distance updates in log. | No network request. |
| 3 | Continue dragging until the indicator shows full pull (usually 60‑80 px). | Indicator fully visible, pull distance near threshold. | Still no request. |
| 4 | Release finger. | Spinner appears, list locks, network request fires. | Request sent, UI shows loading state. |
| 5 | Wait for response (simulate success). | List updates with new data, spinner disappears, list unlocks. | UI returns to idle, fresh data visible. |
| 6 | Repeat step 2‑5 but release before threshold. | Indicator retracts, no request. | No network activity. |
| 7 | Perform a rapid double pull: pull to trigger, release, pull again before spinner hides. | Second pull ignored or queues after first completes. | No duplicate request, UI stable. |
| 8 | Enable network throttling to “Slow 3G”. Repeat step 4. | Spinner stays longer; eventual timeout or error handling visible. | Appropriate error UI, no crash. |
| 9 | Turn off network (offline). Repeat step 4. | Immediate error toast, spinner hides. | Graceful degradation, no JS error. |
| 10 | Open a modal dialog that overlays the list. Attempt PTR. | Gesture ignored; no spinner. | Modal retains focus, PTR blocked. |
| 11 | Enable VoiceOver, focus on list, perform PTR. | Hear “Refreshing…” then “Updated X items”. | Live region updates correctly. |
| 12 | With keyboard focus on page, press Ctrl+R (or custom shortcut). | Same PTR behavior as touch. | Keyboard accessibility satisfied. |
| 13 | Open DevTools, manually set document.documentElement.style.setProperty('--pull-distance', '500px') (if your CSS uses a variable). | Observe whether request clamps or errors. | No out‑of‑bounds parameter sent. |
| 14 | Review console for any uncaught exceptions during any step. | No stack traces. | JS error‑free. |
Observables and Logging
During manual runs, watch for:
- Stale UI – indicator remains after request finishes.
- Jank – frame drops visible in DevTools Performance tab while pulling.
- Double fetch – network panel shows two overlapping requests for the same pull.
- Accessibility gaps – axe reports missing
aria-liveorrole="status"on the spinner container. - Security leaks – request URL contains session tokens in query string (visible in Network tab).
Document each anomaly with a screenshot, the exact pull distance (read from your log), and the browser/device combo. This creates a reproducible bug report that developers can act on.
Automated Testing Strategies
Automation provides regression safety and lets you run the matrix on every commit. Below are layers: unit, integration, and end‑to‑end, however, must be designed to avoid flakiness caused by timing or device‑specific touch heuristics.
Unit Tests for Refresh Logic
Isolate the pure functions that compute pull distance, decide state transitions, and generate request parameters. Example using Jest:
// ptrLogic.js
export function computeState({pullDistance, threshold, isFetching}) {
if (isFetching) return 'refreshing';
if (pullDistance >= threshold) return 'readyToRefresh';
return 'pulling';
}
export function shouldTrigger(pullDistance, threshold) {
return pullDistance >= threshold;
}
// ptrLogic.test.js
import { computeState, shouldTrigger } from './ptrLogic';
test('returns pulling when below threshold', () => {
expect(computeState({pullDistance: 30, threshold: 80, isFetching: false}))
.toBe('pulling');
});
test('returns readyToRefresh when at or above threshold', () => {
expect(computeState({pullDistance: 80, threshold: 80, isFetching: false}))
.toBe('readyToRefresh');
});
test('blocks new trigger while fetching', () => {
expect(computeState({pullDistance: 120, threshold: 80, isFetching: true}))
.toBe('refreshing');
});
These tests run in milliseconds and guard against regressions in the state machine.
Integration Tests with Mocked Network
Integration tests render the component (or a thin harness) and stub the data layer. Using React Testing Library + MSW (Mock Service Worker) as an example:
// PTR.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import PTRComponent from './PTRComponent';
const server = setupServer(
rest.get('/api/items', (req, res, ctx) => {
return res(ctx.json({items: [{id: 1, title: 'First'}]}));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('pull to refresh fetches new data', async () => {
render(<PTRComponent />);
// initial load
expect(screen.getByText(/First/i)).toBeInTheDocument();
// simulate touch start
const pullHandle = screen.getByRole('region', {name: /pull handle/i});
fireEvent.touchStart(pullHandle, {touches: [{clientX: 0, clientY: 0}]});
// simulate moving down 100px
fireEvent.touchMove(pullHandle, {touches: [{clientX: 0, clientY: 100}]});
// simulate release
fireEvent.touchEnd(pullHandle, {touches: []});
// wait for loading indicator
expect(screen.getByRole('status')).toHaveAccessibleName(/refreshing/i);
// wait for mock to resolve
await screen.findByText(/Second/i); // assume server returns new item on second call
expect(screen.getByRole('status')).not.toBeInTheDocument();
});
Key points:
- Use
fireEvent.touchStart/Move/Endto emulate the gesture. - MSW lets you control latency, error codes, and abort behavior.
- Assert on ARIA live region (
role="status"oraria-live="polite").
End‑to‑End Tests with Cypress/Playwright
E2E tests validate the real browser behavior, including CSS transforms and scroll coupling. Below is a Playwright snippet that covers the happy path, error path, and a rapid double pull.
// ptr.spec.js
const { test, expect } = require('@playwright/test');
test.describe('Pull-to-Refresh', () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://example.com/feed');
await page.waitForSelector('text=Item 1');
});
test('happy path triggers refresh and updates list', async ({ page }) => {
const pullHandle = page.locator('[data-ptr-handle]');
// pull down 120px
await pullHandle.hover();
await page.mouse.move(0, 0);
await page.mouse.down();
await page.mouse.move(0, 120, {steps: 5});
await page.mouse.up();
// wait for spinner
await expect(page.locator('[role=status]')).toHaveText(/refreshing/i);
// mock server to return new items
await page.route('**/api/items', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({items: [{id: 2, title: 'Item 2'}]})
});
});
// wait for new item
await expect(page.locator('text=Item 2')).toBeVisible();
// spinner gone
await expect(page.locator('[role=status]')).not.toBeVisible();
});
test('network error shows toast and does not duplicate request', async ({ page }) => {
await page.route('**/api/items', async route => {
await route.fulfill({status: 500});
});
const pullHandle = page.locator('[data-ptr-handle]');
await pullHandle.hover();
await page.mouse.move(0, 0);
await page.mouse.down();
await page.mouse.move(0, 120, {steps: 5});
await page.mouse.up();
await expect(page.locator('[role=status]')).toHaveText(/refreshing/i);
await expect(page.locator('text=Something went wrong')).toBeVisible();
await expect(page.locator('[role=status]')).not.toBeVisible();
// ensure only one request was made
const [request] = await page.waitForRequest('**/api/items');
expect(request).toBeDefined();
// no second request within 2s
await expect(page.waitForRequest('**/api/items', {timeout: 2000})).toBeRejected();
});
test('rapid double pull does not cause duplicate fetch', async ({ page }) => {
let requestCount = 0;
await page.route('**/api/items', async route => {
requestCount++;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({items: []})
});
});
const pullHandle = page.locator('[data-ptr-handle]');
// first pull
await pullHandle.hover();
await page.mouse.move(0, 0);
await page.mouse.down();
await page.mouse.move(0, 120, {steps: 5});
await page.mouse.up();
await page.waitForTimeout(100); // before spinner hides
// second pull immediately after
await pullHandle.hover();
await page.mouse.move(0, 0);
await page.mouse.down();
await page.mouse.move(0, 120, {steps: 5});
await page.mouse.up();
// wait for both to settle
await page.waitForTimeout(800);
expect(requestCount).toBe(1);
});
});
Why Playwright/Cypress?
- Both expose low‑level mouse/touch APIs that let you precisely control drag distance and timing.
- Automatic waiting reduces flakiness (e.g., waiting for the spinner to appear).
- Network routing lets you simulate latency, errors, and aborts without touching the backend.
Performance and Stress Testing
Pull‑to‑refresh can cause layout thrash if the indicator animates via layout‑changing properties (e.g., top, height). Use the Chrome DevTools Performance panel to record a pull gesture and verify:
- Main thread time < 16 ms per frame (aim for 60 fps).
- GPU usage – prefer
transformandopacityfor animation. - Layout shifts – zero cumulative layout shift (CLS) during the pull.
Automate this with Lighthouse CI in CI:
lighthouse https://example.com/feed --preset=preset --only-categories=performance --output=json --output-path=./lhr.json
Then assert that the cumulative-layout-shift audit score is >0.9.
Tooling and Techniques Specific to Web
Testing PTR effectively requires more than generic click helpers; you need to emulate the physics of a drag and expose internal state for assertions.
Intercepting Scroll and Touch Events
If your PTR implementation relies on a custom wrapper that prevents default scroll, you can bypass the gesture layer and directly manipulate the wrapper’s scrollTop or CSS transform. Example helper for Playwright:
async function setPullDistance(page, px) {
const handle = await page.$('[data-ptr-wrapper]');
await handle.evaluate((el, distance) => {
el.style.transform = `translateY(${distance}px)`;
// optionally update internal state if exposed via a global
window.__ptrState__ = {pullDistance: distance};
}, page, px);
}
Then call await setPullDistance(page, 90); to simulate a pull beyond threshold without moving the mouse. This speeds up tests and removes dependence on device pixel ratio.
Mocking Pull Distance with CSS/JS
Some teams expose a debug prop (e.g., data-debug-ptr="true") that renders a visible slider letting testers drag a thumb to set pull distance. In production, the prop is omitted; in test builds, you enable it via an environment variable:
<div data-ptr-wrapper data-debug-ptr="true">
<div class="ptr-slider" draggable="true"></div>
<!-- list -->
</div>
In your test suite, you can set the slider’s left style to a specific pixel value and trigger a change event.
Using DevTools to Simulate Gestures
Chrome DevTools > Sensors > Touch lets you record and playback touch sequences. You can save a JSON gesture file and replay it in an automated script via Puppeteer:
const gestures = JSON.parse(await fs.promises.readFile('ptr-pull.json', 'utf8'));
await page.touchscreen.tap(0, 0); // start
for (const point of gestures) {
await page.touchscreen.tap(point.x, point.y);
}
await page.touchscreen.tap(0, 0); // end
This reproduces the exact finger path a human tester used, useful for regression on device‑specific quirks.
Leveraging Testing Libraries
- @testing-library/user-event – provides
user.drag()which abstracts pointer events and works across jsdom and real browsers. - @testing-library/react –
findByRolequeries for ARIA live regions. - jest-fetch-mock or msw – to mock fetch/XMLHttpRequest.
Combine them for a readable test:
import user from '@testing-library/user-event';
import { render, screen } from '@testing-library/react';
import PTR from './PTR';
test('user drag triggers refresh', async () => {
render(<PTR />);
const handle = screen.getByRole('region', {name: /pull handle/i});
await user.drag(handle, {x: 0, y: 120}); // drag down 120px
await expect(screen.getByRole('status')).toHaveAccessibleName(/refreshing/i);
});
Concrete Code Examples
Below are self‑contained snippets you can paste into a sandbox to see PTR in action, plus the corresponding test harnesses.
Plain JavaScript Implementation
<!DOCTYPE html>
<html>
<head>
<style>
#ptr-wrapper {
overflow: hidden;
position: relative;
}
#ptr-content {
will-change: transform;
transition: transform 0.2s ease-out;
}
#ptr-indicator {
position: absolute;
top: -40px;
left: 50%;
transform: translateX(-50%);
opacity: 0;
transition: opacity 0.2s;
}
#ptr-wrapper.refreshing #ptr-indicator {
opacity: 1;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: translateX(-50%) rotate(360deg); }
}
</style>
</head>
<body>
<div id="ptr-wrapper">
<div id="ptr-indicator">⟳</div>
<div id="ptr-content">
<ul id="list"><li>Item 1</li><li>Item 2</li></ul>
</div>
</div>
<script>
const wrapper = document.getElementById('ptr-wrapper');
const indicator = document.getElementById('ptr-indicator');
const content = document.getElementById('ptr-content');
const list = document.getElementById('list');
let pullDistance = 0;
const threshold = 80;
let fetching = false;
function updateIndicator() {
const progress = Math.min(pullDistance / threshold, 1);
indicator.style.opacity = progress;
content.style.transform = `translateY(${pullDistance}px)`;
if (pullDistance >= threshold && !fetching) {
wrapper.classList.add('refreshing');
fetchData();
} else {
wrapper.classList.remove('refreshing');
}
}
function fetchData() {
fetching = true;
// simulate network
setTimeout(() => {
// prepend new item
const li = document.createElement('li');
li.textContent = `Item ${list.children.length + 1}`;
list.prepend(li);
pullDistance = 0;
fetching = false;
updateIndicator();
}, 1200);
}
wrapper.addEventListener('touchstart', e => {
if (e.touches.length === 1) {
wrapper.dataset.startY = e.touches[0].clientY;
}
});
wrapper.addEventListener('touchmove', e => {
if (!wrapper.dataset.startY) return;
const currentY = e.touches[0].clientY;
const delta = currentY - parseFloat(wrapper.dataset.startY, 10);
// only pull down
if (delta > 0) {
pullDistance = Math.min(delta, threshold * 2); // clamp
updateIndicator();
}
});
wrapper.addEventListener('touchend', () => {
delete wrapper.dataset.startY;
if (pullDistance >= threshold && !fetching) {
// already triggered in touchmove when crossing threshold
} else {
pullDistance = 0;
updateIndicator();
}
});
</script>
</body>
</html>
Test with Playwright (happy path):
test('plain JS PTR works', async ({ page }) => {
await page.goto('file:///path/to/ptr.html');
const wrapper = await page.$('#ptr-wrapper');
// pull down 100px
await wrapper.evaluate((el, dy) => {
el.dataset.startY = '0';
el.dispatchEvent(new TouchEvent('touchstart', {touches: [{clientX:0, clientY:0}]}));
el.dispatchEvent(new TouchEvent('touchmove', {touches: [{clientX:0, clientY:dy}]}));
el.dispatchEvent(new TouchEvent('touchend', {}));
}, 100);
await page.waitForTimeout(1300); // wait for mock fetch
const firstItem = await page.locator('#list li').first().textContent();
expect(firstItem).toContain('Item 3'); // original two items + new one
});
React Hook Example
// usePullToRefresh.js
import { useState, useEffect, useRef } from 'react';
import { useDrag } from 'react-use-gesture'; // optional, but shows gesture lib
export function usePullToRefresh({onRefresh, threshold = 80}) {
const [pullDistance, setPullDistance] = useState(0);
const [state, setState] = useState('idle'); // idle | pulling | refreshing
const wrapperRef = useRef(null);
const bind = useDrag(
({offset: [_, y]}) => {
if (y < 0) return; // ignore upward drag
const d = Math.min(y, threshold * 2);
setPullDistance(d);
if (d >= threshold && state === 'idle') setState('pulling');
},
{domain: [0, Infinity]}
);
useEffect(() => {
if (pullDistance >= threshold && state === 'pulling') {
setState('refreshing');
onRefresh().finally(() => {
setPullDistance(0);
setState('idle');
});
}
}, [pullDistance, state, onRefresh]);
useEffect(() => {
if (state === 'idle' && pullDistance > 0) {
setPullDistance(0);
}
}, [state]);
return {wrapperRef, pullDistance, state, bind};
}
Component usage:
import { usePullToRefresh } from './usePullToRefresh';
export function Feed() {
const [items, setItems] = useState(initialItems);
const {wrapperRef, pullDistance, state, bind} = usePullToRefresh({
onRefresh: async () => {
const newItems = await fetchNewItems();
setItems(prev => [...newItems, ...prev]);
}
});
return (
<div ref={wrapperRef} {...bind()} style={{transform: `translateY(${pullDistance}px)`}}>
{state === 'refreshing' && <div role="status" aria-live="polite">Refreshing…</div>}
<ul>{items.map(i => <li key={i.id}>{i.title}</li>)</ul>
</div>
);
}
Test with React Testing Library + user-event:
import { render, screen } from '@testing-library/react';
import user from '@testing-library/user-event';
import Feed from './Feed';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
rest.get('/api/new', (req, res, ctx) => {
return res(ctx.json({items: [{id: 99, title: 'Fresh'}]}));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('pull to refresh adds new item', async () => {
render(<Feed />);
const wrapper = screen.getByRole('region', {name: /feed/i});
await user.drag(wrapper, {x: 0, y: 120}); // drag down
await screen.findByRole('status', {name: /refreshing/i});
await screen.findByText(/Fresh/i);
expect(screen.getByRole('status')).not.toBeInTheDocument();
});
Vue 3 Composition API Example
<script setup>
import { ref } from 'vue';
import { useDrag } from '@vueuse/core';
const pullDistance = ref(0);
const state = ref('idle');
const threshold = 80;
const wrapper = ref(null);
const {bind} = useDrag(wrapper, {
start: () => {},
move: ([_, y]) => {
if (y < 0) return;
pullDistance.value = Math.min(y, threshold * 2);
if (y >= threshold && state.value === 'idle') state.value = 'pulling';
},
end: () => {
if (pullDistance.value >= threshold && state.value === 'pulling') {
state.value = 'refreshing';
refresh().finally(() => {
pullDistance.value = 0;
state.value = 'idle';
});
} else {
pullDistance.value = 0;
state.value = 'idle';
}
}
});
async function refresh() {
const resp = await fetch('/api/items');
const data = await resp.json();
// prepend
items.value = [...data, ...items.value];
}
</script>
<template>
<div ref="wrapper" v-bind="bind" :style="{transform: `translateY(${pullDistance}px)`}">
<div v-if="state === 'refreshing'" role="status" aria-live="polite">Refreshing…</div>
<ul>
<li v-for="item in items" :key="item.id">{{ item.title }}
Test Your App Autonomously
Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.
Try SUSA Free