Skip to content

Commit 3bef32f

Browse files
martrappnatemoo-re
andauthored
Fix: Retain focus for persisted input elements during view transitions (#8813)
* add new e2e test: persist focus on transition * save and restore focus during swap --------- Co-authored-by: Nate Moore <[email protected]>
1 parent 0abff97 commit 3bef32f

File tree

7 files changed

+93
-4
lines changed

7 files changed

+93
-4
lines changed

.changeset/three-toes-talk.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Save and restore focus for persisted input elements during view transitions

packages/astro/e2e/fixtures/view-transitions/astro.config.mjs

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import nodejs from '@astrojs/node';
44

55
// https://astro.build/config
66
export default defineConfig({
7-
output: 'server',
7+
output: 'hybrid',
88
adapter: nodejs({ mode: 'standalone' }),
99
integrations: [react()],
1010
redirects: {

packages/astro/e2e/fixtures/view-transitions/src/components/Layout.astro

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const { link } = Astro.props as Props;
1818
margin: auto;
1919
}
2020
</style>
21-
<link rel="stylesheet" href="/style.css">
21+
<link rel="stylesheet" href="/styles.css">
2222
<ViewTransitions />
2323
<DarkMode />
2424
<meta name="script-executions" content="0">
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
import Layout from '../components/Layout.astro';
3+
---
4+
<Layout>
5+
<h2>Form 1</h2>
6+
<form transition:persist>
7+
<input id="input" type="text" name="name" autocomplete="false"/>
8+
</form>
9+
10+
<script>
11+
import {navigate} from "astro:transitions/client"
12+
const form = document.querySelector("form");
13+
form.addEventListener("submit", (e) => {
14+
console.log("submit");
15+
e.preventDefault();
16+
navigate(`${location.pathname}?name=${input.value}`,{history: "replace"});
17+
return false;
18+
});
19+
</script>
20+
</Layout>

packages/astro/e2e/fixtures/view-transitions/src/pages/query.astro

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
import Layout from '../components/Layout.astro';
3-
3+
export const prerender = false;
4+
// this works only with SSR, not with SSG. E2e tests run with output=hybrid or server
45
const page = Astro.url.searchParams.get('page') || 1;
56
---
67
<Layout>

packages/astro/e2e/view-transitions.test.js

+21-1
Original file line numberDiff line numberDiff line change
@@ -788,7 +788,7 @@ test.describe('View Transitions', () => {
788788

789789
test('replace history', async ({ page, astro }) => {
790790
await page.goto(astro.resolveUrl('/one'));
791-
// page six loads the router and automatically uses the router to navigate to page 1
791+
792792
let p = page.locator('#one');
793793
await expect(p, 'should have content').toHaveText('Page 1');
794794

@@ -833,4 +833,24 @@ test.describe('View Transitions', () => {
833833
p = page.locator('#one');
834834
await expect(p, 'should have content').toHaveText('Page 1');
835835
});
836+
837+
test('Keep focus on transition', async ({ page, astro }) => {
838+
await page.goto(astro.resolveUrl('/page-with-persistent-form'));
839+
let locator = page.locator('h2');
840+
await expect(locator, 'should have content').toHaveText('Form 1');
841+
842+
locator = page.locator('#input');
843+
await locator.type('Hello');
844+
await expect(locator).toBeFocused();
845+
await locator.press('Enter');
846+
847+
await page.waitForURL(/.*name=Hello/);
848+
locator = page.locator('h2');
849+
await expect(locator, 'should have content').toHaveText('Form 1');
850+
locator = page.locator('#input');
851+
await expect(locator).toBeFocused();
852+
853+
await locator.type(' World');
854+
await expect(locator).toHaveValue('Hello World');
855+
});
836856
});

packages/astro/src/transitions/router.ts

+43
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,45 @@ async function updateDOM(
215215
return null;
216216
};
217217

218+
type SavedFocus = {
219+
activeElement: HTMLElement | null;
220+
start?: number | null;
221+
end?: number | null;
222+
};
223+
224+
const saveFocus = (): SavedFocus => {
225+
const activeElement = document.activeElement as HTMLElement;
226+
// The element that currently has the focus is part of a DOM tree
227+
// that will survive the transition to the new document.
228+
// Save the element and the cursor position
229+
if (activeElement?.closest('[data-astro-transition-persist]')) {
230+
if (
231+
activeElement instanceof HTMLInputElement ||
232+
activeElement instanceof HTMLTextAreaElement
233+
) {
234+
const start = activeElement.selectionStart;
235+
const end = activeElement.selectionEnd;
236+
return { activeElement, start, end };
237+
}
238+
return { activeElement };
239+
} else {
240+
return { activeElement: null };
241+
}
242+
};
243+
244+
const restoreFocus = ({ activeElement, start, end }: SavedFocus) => {
245+
if (activeElement) {
246+
activeElement.focus();
247+
if (
248+
activeElement instanceof HTMLInputElement ||
249+
activeElement instanceof HTMLTextAreaElement
250+
) {
251+
activeElement.selectionStart = start!;
252+
activeElement.selectionEnd = end!;
253+
}
254+
}
255+
};
256+
218257
const swap = () => {
219258
// swap attributes of the html element
220259
// - delete all attributes from the current document
@@ -263,6 +302,8 @@ async function updateDOM(
263302
// Persist elements in the existing body
264303
const oldBody = document.body;
265304

305+
const savedFocus = saveFocus();
306+
266307
// this will reset scroll Position
267308
document.body.replaceWith(newDocument.body);
268309
for (const el of oldBody.querySelectorAll(`[${PERSIST_ATTR}]`)) {
@@ -275,6 +316,8 @@ async function updateDOM(
275316
}
276317
}
277318

319+
restoreFocus(savedFocus);
320+
278321
if (popState) {
279322
scrollTo(popState.scrollX, popState.scrollY); // usings 'auto' scrollBehavior
280323
} else {

0 commit comments

Comments
 (0)