Skip to content

Commit

Permalink
Turbo Streams: Manage element focus
Browse files Browse the repository at this point in the history
When a `<turbo-stream>` modifies the document, it has the potential to
affect which element has focus.

For example, consider an element with an `[id]` that has focus:

```html
<label for="an-input">
<input id="an-input" value="an invalid value"> <!-- this element has focus -->
```

Next, consider a `<turbo-stream>` element to replace it:

```html
<turbo-stream action="replace" target="an-input">
  <template>
    <input id="an-input" value="an invalid value" class="invalid-input">
  </template>
</turbo-stream>
```

Prior to this commit, rendering that `<turbo-stream>` would remove the
element with focus, and never restore it.

After this commit, the `Session` will capture the `[id]` value of the
element with focus (if there is any), then "restore" focus to an element
in the document with a matching `[id]` attribute _after_ the render.

Similarly, consider a `<turbo-stream>` that appends an element with
`[autofocus]`:

```html
<turbo-stream action="append" targets="body">
  <template>
    <input autofocus>
  </template>
</turbo-stream>
```

Prior to this commit, inserting an `[autofocus]` into the document with
a `<turbo-stream>` had no effect.

After this commit, the `Session` will scan any `<turbo-stream>` elements
its about to render, extracting the first focusable element that
declares an `[autofocus]` attribute.

Once the rendering is complete, it will attempt to autofocus that
element. Several scenarios will prevent that, including:

* there aren't any `[autofocus]` elements in the collection of
  `<turbo-stream>` elements
* the `[autofocus]` element does not exist in the document after the
  rendering is complete
* the document already has an element with focus
  • Loading branch information
seanpdoyle committed Sep 11, 2023
1 parent c44664d commit 0e84543
Show file tree
Hide file tree
Showing 7 changed files with 203 additions and 17 deletions.
6 changes: 1 addition & 5 deletions src/core/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export class Renderer {

focusFirstAutofocusableElement() {
const element = this.connectedSnapshot.firstAutofocusableElement
if (elementIsFocusable(element)) {
if (element) {
element.focus()
}
}
Expand Down Expand Up @@ -80,7 +80,3 @@ export class Renderer {
return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot)
}
}

function elementIsFocusable(element) {
return element && typeof element.focus == "function"
}
11 changes: 3 additions & 8 deletions src/core/snapshot.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { queryAutofocusableElement } from "../util"

export class Snapshot {
constructor(element) {
this.element = element
Expand All @@ -24,14 +26,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() {
Expand Down
63 changes: 60 additions & 3 deletions src/core/streams/stream_message_renderer.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { Bardo } from "../bardo"
import { getPermanentElementById, queryPermanentElementsAll } from "../snapshot"
import { around, elementIsFocusable, queryAutofocusableElement, waitForCallback } from "../../util"

export class StreamMessageRenderer {
render({ fragment }) {
Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), () =>
document.documentElement.appendChild(fragment)
)
Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), () => {
withAutofocusFromFragment(fragment, () => {
withPreservedFocus(() => {
document.documentElement.appendChild(fragment)
})
})
})
}

// Bardo delegate
Expand Down Expand Up @@ -34,3 +39,55 @@ function getPermanentElementMapForFragment(fragment) {

return permanentElementMap
}

async function withAutofocusFromFragment(fragment, callback) {
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) {
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(nodeListOfStreamElements) {
for (const streamElement of nodeListOfStreamElements) {
const elementWithAutofocus = queryAutofocusableElement(streamElement.templateElement.content)

if (elementWithAutofocus) return elementWithAutofocus
}

return null
}
4 changes: 4 additions & 0 deletions src/tests/fixtures/stream.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,9 @@
<div id="messages_3" class="messages">
<div class="message">Third</div>
</div>

<div id="container">
<input id="container-element">
</div>
</body>
</html>
58 changes: 58 additions & 0 deletions src/tests/functional/autofocus_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
<turbo-stream action="append" targets="body">
<template><input id="autofocus-from-stream" autofocus></template>
</turbo-stream>
`)
})
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(`
<turbo-stream action="append" targets="body">
<template><div id="container-from-stream"><input autofocus></div></template>
</turbo-stream>
`)
})
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(`
<turbo-stream action="append" targets="body">
<template><input id="autofocus-from-stream" autofocus></template>
</turbo-stream>
`)
})
await nextBeat()

assert.ok(
await hasSelector(page, "#first-autofocus-element:focus"),
"focuses the first [autofocus] element on the page"
)
})
49 changes: 48 additions & 1 deletion src/tests/functional/stream_tests.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { test } from "@playwright/test"
import { assert } from "chai"
import { nextBeat, nextEventNamed, readEventLogs, waitUntilNoSelector, waitUntilText } from "../helpers/page"
import {
hasSelector,
nextBeat,
nextEventNamed,
readEventLogs,
waitUntilNoSelector,
waitUntilText,
} from "../helpers/page"

test.beforeEach(async ({ page }) => {
await page.goto("/src/tests/fixtures/stream.html")
Expand Down Expand Up @@ -121,3 +128,43 @@ test("test receiving a stream message over SSE", async ({ page }) => {

assert.deepEqual(await messages.allTextContents(), ["First", "Hello world!"])
})

test("test receiving an update stream message preserves focus if the activeElement has an [id]", async ({ page }) => {
await page.locator("input#container-element").focus()
await page.evaluate(() => {
window.Turbo.renderStreamMessage(`
<turbo-stream action="update" target="container">
<template><textarea id="container-element"></textarea></template>
</turbo-stream>
`)
})
await nextBeat()

assert.ok(await hasSelector(page, "textarea#container-element:focus"))
})

test("test receiving a replace stream message preserves focus if the activeElement has an [id]", async ({ page }) => {
await page.locator("input#container-element").focus()
await page.evaluate(() => {
window.Turbo.renderStreamMessage(`
<turbo-stream action="replace" target="container-element">
<template><textarea id="container-element"></textarea></template>
</turbo-stream>
`)
})
await nextBeat()

assert.ok(await hasSelector(page, "textarea#container-element:focus"))
})

test("test receiving a remove stream message preserves focus blurs the activeElement", async ({ page }) => {
await page.locator("#container-element").focus()
await page.evaluate(() => {
window.Turbo.renderStreamMessage(`
<turbo-stream action="remove" target="container-element"></turbo-stream>
`)
})
await nextBeat()

assert.notOk(await hasSelector(page, ":focus"))
})
29 changes: 29 additions & 0 deletions src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,32 @@ export function findClosestRecursively(element, selector) {
)
}
}

export function elementIsFocusable(element) {
const inertDisabledOrHidden = "[inert], :disabled, [hidden], details:not([open]), dialog:not([open])"

return !!element && element.closest(inertDisabledOrHidden) == null && typeof element.focus == "function"
}

export function queryAutofocusableElement(elementOrDocumentFragment) {
for (const element of elementOrDocumentFragment.querySelectorAll("[autofocus]")) {
if (elementIsFocusable(element)) return element
else continue
}

return null
}

export async function around(callback, reader) {
const before = reader()
await waitForCallback(callback)
const after = reader()

return [before, after]
}

export function waitForCallback(callback) {
callback()

return nextAnimationFrame()
}

0 comments on commit 0e84543

Please sign in to comment.