diff --git a/src/core/renderer.ts b/src/core/renderer.ts index e77be9d34..ce64b2bbe 100644 --- a/src/core/renderer.ts +++ b/src/core/renderer.ts @@ -55,7 +55,7 @@ export abstract class Renderer = Snapsh focusFirstAutofocusableElement() { const element = this.connectedSnapshot.firstAutofocusableElement - if (elementIsFocusable(element)) { + if (element) { element.focus() } } @@ -94,7 +94,3 @@ export abstract class Renderer = Snapsh return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot) } } - -function elementIsFocusable(element: any): element is { focus: () => void } { - return element && typeof element.focus == "function" -} diff --git a/src/core/snapshot.ts b/src/core/snapshot.ts index 7d5431117..8bba5d206 100644 --- a/src/core/snapshot.ts +++ b/src/core/snapshot.ts @@ -1,3 +1,5 @@ +import { queryAutofocusableElement } from "../util" + export class Snapshot { readonly element: E @@ -26,14 +28,7 @@ export class Snapshot { } get firstAutofocusableElement() { - const inertDisabledOrHidden = "[inert], :disabled, [hidden], details:not([open]), dialog:not([open])" - - for (const element of this.element.querySelectorAll("[autofocus]")) { - if (element.closest(inertDisabledOrHidden) == null) return element - else continue - } - - return null + return queryAutofocusableElement(this.element) } get permanentElements() { diff --git a/src/core/streams/stream_message_renderer.ts b/src/core/streams/stream_message_renderer.ts index 549e4de8d..e7158b74a 100644 --- a/src/core/streams/stream_message_renderer.ts +++ b/src/core/streams/stream_message_renderer.ts @@ -2,12 +2,17 @@ import { StreamMessage } from "./stream_message" import { StreamElement } from "../../elements/stream_element" import { Bardo, BardoDelegate } from "../bardo" import { PermanentElementMap, getPermanentElementById, queryPermanentElementsAll } from "../snapshot" +import { around, elementIsFocusable, queryAutofocusableElement, waitForCallback } from "../../util" export class StreamMessageRenderer implements BardoDelegate { render({ fragment }: StreamMessage) { - Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), () => - document.documentElement.appendChild(fragment) - ) + Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), () => { + withAutofocusFromFragment(fragment, () => { + withPreservedFocus(() => { + document.documentElement.appendChild(fragment) + }) + }) + }) } enteringBardo(currentPermanentElement: Element, newPermanentElement: Element) { @@ -34,3 +39,55 @@ function getPermanentElementMapForFragment(fragment: DocumentFragment): Permanen return permanentElementMap } + +async function withAutofocusFromFragment(fragment: DocumentFragment, callback: () => void) { + const generatedID = `turbo-stream-autofocus-${Date.now()}` + const turboStreams = fragment.querySelectorAll("turbo-stream") + const elementWithAutofocus = firstAutofocusableElementInStreams(turboStreams) + let willAutofocus = generatedID + + if (elementWithAutofocus) { + if (elementWithAutofocus.id) willAutofocus = elementWithAutofocus.id + + elementWithAutofocus.id = willAutofocus + } + + await waitForCallback(callback) + + const hasNoActiveElement = document.activeElement == null || document.activeElement == document.body + + if (hasNoActiveElement && willAutofocus) { + const elementToAutofocus = document.getElementById(willAutofocus) + + if (elementIsFocusable(elementToAutofocus)) { + elementToAutofocus.focus() + } + if (elementToAutofocus && elementToAutofocus.id == generatedID) { + elementToAutofocus.removeAttribute("id") + } + } +} + +async function withPreservedFocus(callback: () => void) { + const [activeElementBeforeRender, activeElementAfterRender] = await around(callback, () => document.activeElement) + + const restoreFocusTo = activeElementBeforeRender && activeElementBeforeRender.id + + if (restoreFocusTo) { + const elementToFocus = document.getElementById(restoreFocusTo) + + if (elementIsFocusable(elementToFocus) && elementToFocus != activeElementAfterRender) { + elementToFocus.focus() + } + } +} + +function firstAutofocusableElementInStreams(nodes: NodeListOf) { + for (const stream of nodes) { + const elementWithAutofocus = queryAutofocusableElement(stream.templateElement.content) + + if (elementWithAutofocus) return elementWithAutofocus + } + + return null +} diff --git a/src/tests/fixtures/stream.html b/src/tests/fixtures/stream.html index 164f45256..85e8c94e4 100644 --- a/src/tests/fixtures/stream.html +++ b/src/tests/fixtures/stream.html @@ -35,5 +35,9 @@
Third
+ +
+ +
diff --git a/src/tests/functional/autofocus_tests.ts b/src/tests/functional/autofocus_tests.ts index 06fa68e46..e69d5e5a1 100644 --- a/src/tests/functional/autofocus_tests.ts +++ b/src/tests/functional/autofocus_tests.ts @@ -108,3 +108,61 @@ test("test navigating a frame with a turbo-frame targeting the frame autofocuses "focuses the first [autofocus] element in frame" ) }) + +test("test receiving a Turbo Stream message with an [autofocus] element when the activeElement is the document", async ({ + page, +}) => { + await page.evaluate(() => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } + window.Turbo.renderStreamMessage(` + + + + `) + }) + await nextBeat() + + assert.ok( + await hasSelector(page, "#autofocus-from-stream:focus"), + "focuses the [autofocus] element in from the turbo-stream" + ) +}) + +test("test autofocus from a Turbo Stream message does not leak a placeholder [id]", async ({ page }) => { + await page.evaluate(() => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } + window.Turbo.renderStreamMessage(` + + + + `) + }) + await nextBeat() + + assert.ok( + await hasSelector(page, "#container-from-stream input:focus"), + "focuses the [autofocus] element in from the turbo-stream" + ) +}) + +test("test receiving a Turbo Stream message with an [autofocus] element when an element within the document has focus", async ({ + page, +}) => { + await page.evaluate(() => { + window.Turbo.renderStreamMessage(` + + + + `) + }) + await nextBeat() + + assert.ok( + await hasSelector(page, "#first-autofocus-element:focus"), + "focuses the first [autofocus] element on the page" + ) +}) diff --git a/src/tests/functional/stream_tests.ts b/src/tests/functional/stream_tests.ts index 356ece95f..5a54a57a9 100644 --- a/src/tests/functional/stream_tests.ts +++ b/src/tests/functional/stream_tests.ts @@ -1,6 +1,6 @@ import { test } from "@playwright/test" import { assert } from "chai" -import { nextBeat, nextEventNamed, readEventLogs } from "../helpers/page" +import { hasSelector, nextBeat, nextEventNamed, readEventLogs } from "../helpers/page" test.beforeEach(async ({ page }) => { await page.goto("/src/tests/fixtures/stream.html") @@ -50,7 +50,7 @@ test("test receiving a message without a template", async ({ page }) => { `) ) - assert.equal(await await page.locator("#messages").count(), 0, "removes target element") + assert.equal(await page.locator("#messages").count(), 0, "removes target element") }) test("test receiving a message with a