Skip to content

Commit

Permalink
Fix: Retain focus for persisted input elements during view transitions (
Browse files Browse the repository at this point in the history
#8813)

* add new e2e test: persist focus on transition

* save and restore focus during swap

---------

Co-authored-by: Nate Moore <[email protected]>
  • Loading branch information
martrapp and natemoo-re authored Oct 11, 2023
1 parent 0abff97 commit 3bef32f
Show file tree
Hide file tree
Showing 7 changed files with 93 additions and 4 deletions.
5 changes: 5 additions & 0 deletions .changeset/three-toes-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Save and restore focus for persisted input elements during view transitions
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import nodejs from '@astrojs/node';

// https://astro.build/config
export default defineConfig({
output: 'server',
output: 'hybrid',
adapter: nodejs({ mode: 'standalone' }),
integrations: [react()],
redirects: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const { link } = Astro.props as Props;
margin: auto;
}
</style>
<link rel="stylesheet" href="/style.css">
<link rel="stylesheet" href="/styles.css">
<ViewTransitions />
<DarkMode />
<meta name="script-executions" content="0">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
import Layout from '../components/Layout.astro';
---
<Layout>
<h2>Form 1</h2>
<form transition:persist>
<input id="input" type="text" name="name" autocomplete="false"/>
</form>

<script>
import {navigate} from "astro:transitions/client"
const form = document.querySelector("form");
form.addEventListener("submit", (e) => {
console.log("submit");
e.preventDefault();
navigate(`${location.pathname}?name=${input.value}`,{history: "replace"});
return false;
});
</script>
</Layout>
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
import Layout from '../components/Layout.astro';
export const prerender = false;
// this works only with SSR, not with SSG. E2e tests run with output=hybrid or server
const page = Astro.url.searchParams.get('page') || 1;
---
<Layout>
Expand Down
22 changes: 21 additions & 1 deletion packages/astro/e2e/view-transitions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -788,7 +788,7 @@ test.describe('View Transitions', () => {

test('replace history', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/one'));
// page six loads the router and automatically uses the router to navigate to page 1

let p = page.locator('#one');
await expect(p, 'should have content').toHaveText('Page 1');

Expand Down Expand Up @@ -833,4 +833,24 @@ test.describe('View Transitions', () => {
p = page.locator('#one');
await expect(p, 'should have content').toHaveText('Page 1');
});

test('Keep focus on transition', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/page-with-persistent-form'));
let locator = page.locator('h2');
await expect(locator, 'should have content').toHaveText('Form 1');

locator = page.locator('#input');
await locator.type('Hello');
await expect(locator).toBeFocused();
await locator.press('Enter');

await page.waitForURL(/.*name=Hello/);
locator = page.locator('h2');
await expect(locator, 'should have content').toHaveText('Form 1');
locator = page.locator('#input');
await expect(locator).toBeFocused();

await locator.type(' World');
await expect(locator).toHaveValue('Hello World');
});
});
43 changes: 43 additions & 0 deletions packages/astro/src/transitions/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,45 @@ async function updateDOM(
return null;
};

type SavedFocus = {
activeElement: HTMLElement | null;
start?: number | null;
end?: number | null;
};

const saveFocus = (): SavedFocus => {
const activeElement = document.activeElement as HTMLElement;
// The element that currently has the focus is part of a DOM tree
// that will survive the transition to the new document.
// Save the element and the cursor position
if (activeElement?.closest('[data-astro-transition-persist]')) {
if (
activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement
) {
const start = activeElement.selectionStart;
const end = activeElement.selectionEnd;
return { activeElement, start, end };
}
return { activeElement };
} else {
return { activeElement: null };
}
};

const restoreFocus = ({ activeElement, start, end }: SavedFocus) => {
if (activeElement) {
activeElement.focus();
if (
activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement
) {
activeElement.selectionStart = start!;
activeElement.selectionEnd = end!;
}
}
};

const swap = () => {
// swap attributes of the html element
// - delete all attributes from the current document
Expand Down Expand Up @@ -263,6 +302,8 @@ async function updateDOM(
// Persist elements in the existing body
const oldBody = document.body;

const savedFocus = saveFocus();

// this will reset scroll Position
document.body.replaceWith(newDocument.body);
for (const el of oldBody.querySelectorAll(`[${PERSIST_ATTR}]`)) {
Expand All @@ -275,6 +316,8 @@ async function updateDOM(
}
}

restoreFocus(savedFocus);

if (popState) {
scrollTo(popState.scrollX, popState.scrollY); // usings 'auto' scrollBehavior
} else {
Expand Down

0 comments on commit 3bef32f

Please sign in to comment.