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 Dec 31, 2022
1 parent 9defbe0 commit 76c30e1
Show file tree
Hide file tree
Showing 7 changed files with 199 additions and 18 deletions.
6 changes: 1 addition & 5 deletions src/core/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export abstract class Renderer<E extends Element, S extends Snapshot<E> = Snapsh

focusFirstAutofocusableElement() {
const element = this.connectedSnapshot.firstAutofocusableElement
if (elementIsFocusable(element)) {
if (element) {
element.focus()
}
}
Expand Down Expand Up @@ -94,7 +94,3 @@ export abstract class Renderer<E extends Element, S extends Snapshot<E> = Snapsh
return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot)
}
}

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

export class Snapshot<E extends Element = Element> {
readonly element: E

Expand Down Expand Up @@ -26,14 +28,7 @@ export class Snapshot<E extends Element = Element> {
}

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.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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<StreamElement>("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<StreamElement>) {
for (const stream of nodes) {
const elementWithAutofocus = queryAutofocusableElement(stream.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.ts
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"
)
})
44 changes: 42 additions & 2 deletions src/tests/functional/stream_tests.ts
Original file line number Diff line number Diff line change
@@ -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")
Expand Down Expand Up @@ -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 <script> element", async ({ page }) => {
Expand Down Expand Up @@ -123,3 +123,43 @@ test("test receiving a stream message asynchronously", 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"))
})
31 changes: 31 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,34 @@ export function findClosestRecursively<E extends Element>(element: Element | nul
)
}
}

type FocusableElement = Element & { focus: () => void }

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

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

export function queryAutofocusableElement(root: Element | DocumentFragment): FocusableElement | null {
for (const element of root.querySelectorAll("[autofocus]")) {
if (elementIsFocusable(element)) return element
else continue
}

return null
}

export async function around<T>(callback: () => void, reader: () => T): Promise<[T, T]> {
const before = reader()
await waitForCallback(callback)
const after = reader()

return [before, after]
}

export function waitForCallback(callback: () => void): Promise<void> {
callback()

return nextAnimationFrame()
}

0 comments on commit 76c30e1

Please sign in to comment.