From 3bef32f81c56bc600ca307f1bd40787e23e625a5 Mon Sep 17 00:00:00 2001 From: Martin Trapp <94928215+martrapp@users.noreply.github.com> Date: Thu, 12 Oct 2023 00:08:44 +0200 Subject: [PATCH] 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 --- .changeset/three-toes-talk.md | 5 +++ .../view-transitions/astro.config.mjs | 2 +- .../src/components/Layout.astro | 2 +- .../src/pages/page-with-persistent-form.astro | 20 +++++++++ .../view-transitions/src/pages/query.astro | 3 +- packages/astro/e2e/view-transitions.test.js | 22 +++++++++- packages/astro/src/transitions/router.ts | 43 +++++++++++++++++++ 7 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 .changeset/three-toes-talk.md create mode 100644 packages/astro/e2e/fixtures/view-transitions/src/pages/page-with-persistent-form.astro diff --git a/.changeset/three-toes-talk.md b/.changeset/three-toes-talk.md new file mode 100644 index 000000000000..a6a879ad6554 --- /dev/null +++ b/.changeset/three-toes-talk.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Save and restore focus for persisted input elements during view transitions diff --git a/packages/astro/e2e/fixtures/view-transitions/astro.config.mjs b/packages/astro/e2e/fixtures/view-transitions/astro.config.mjs index 68fdc8e2e1eb..2b22ff9cf3f3 100644 --- a/packages/astro/e2e/fixtures/view-transitions/astro.config.mjs +++ b/packages/astro/e2e/fixtures/view-transitions/astro.config.mjs @@ -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: { diff --git a/packages/astro/e2e/fixtures/view-transitions/src/components/Layout.astro b/packages/astro/e2e/fixtures/view-transitions/src/components/Layout.astro index ddafb98a993f..7ef7b93f8897 100644 --- a/packages/astro/e2e/fixtures/view-transitions/src/components/Layout.astro +++ b/packages/astro/e2e/fixtures/view-transitions/src/components/Layout.astro @@ -18,7 +18,7 @@ const { link } = Astro.props as Props; margin: auto; } - + diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/page-with-persistent-form.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/page-with-persistent-form.astro new file mode 100644 index 000000000000..c150726ed191 --- /dev/null +++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/page-with-persistent-form.astro @@ -0,0 +1,20 @@ +--- +import Layout from '../components/Layout.astro'; +--- + +

Form 1

+
+ +
+ + +
diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/query.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/query.astro index 44dd03ce0f22..e9bee1f4407c 100644 --- a/packages/astro/e2e/fixtures/view-transitions/src/pages/query.astro +++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/query.astro @@ -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; --- diff --git a/packages/astro/e2e/view-transitions.test.js b/packages/astro/e2e/view-transitions.test.js index 559592fbade0..d2c14aabdabc 100644 --- a/packages/astro/e2e/view-transitions.test.js +++ b/packages/astro/e2e/view-transitions.test.js @@ -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'); @@ -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'); + }); }); diff --git a/packages/astro/src/transitions/router.ts b/packages/astro/src/transitions/router.ts index c21392e3aa6f..869ed87af5fa 100644 --- a/packages/astro/src/transitions/router.ts +++ b/packages/astro/src/transitions/router.ts @@ -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 @@ -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}]`)) { @@ -275,6 +316,8 @@ async function updateDOM( } } + restoreFocus(savedFocus); + if (popState) { scrollTo(popState.scrollX, popState.scrollY); // usings 'auto' scrollBehavior } else {