Skip to content

Commit

Permalink
Toggle [aria-busy="true"] during requests (#199)
Browse files Browse the repository at this point in the history
Toggle the `[aria-busy="true"]` attribute to be present on a
`<turbo-frame>` element when its navigating, a `<form>` element when
it's submitting, or on the `<html>` element during any request, then
remove the attribute after the submission or navigation completes.

This is a change from the `turbo-frame[busy]` attribute, but aims to
achieve the same purpose. For the sake of backwards compatibility, Turbo
will continue to toggle the `[busy]` attribute during Frame navigations.

By unifying a single, consistent attribute, consumer applications can
use a single attribute CSS selector at different depths within their
page to hide or show loading indicators.

For example, consider a loading indication spinner animation within a
submit button that animates while a submission is in progress:

```html
<style>
  .loading-spinner                      { display: none; }
  [aria-busy="true"] .loading-spinner   { display: block; }
</style>

<form action="/posts">
  <!-- ... -->
  <button type="submit">
    <span class="loading-spinner>...</span>
    <span>Create Post</span>
  </button>
</form>
```

Similarly, consider a `turbo-frame` scoped loading bar:

```html
<style>
  .loading-bar                          { display: none; }
  [aria-busy="true"] .loading-bar       { display: block; }
</style>
<a href="/content" data-turbo-frame="content">...</a>
<turbo-frame id="content">
  <progress id="content-loading-bar" class="loading-bar" value="0" data-turbo-permanent></progress>

  <!-- ... -->
</turbo-frame>
```

Finally, consider the same `.loading-bar` for page-wide navigation:

```html
<style>
  .loading-bar                          { display: none; }
  [aria-busy="true"] .loading-bar       { display: block; }
</style>

<nav>
  <progress id="loading-bar" class="loading-bar" value="0" data-turbo-permanent></progress>
</nav>

<!-- ... -->

<a href="/posts/1">...</a>
```
  • Loading branch information
seanpdoyle authored Nov 11, 2021
1 parent 274d369 commit 4a13b6d
Show file tree
Hide file tree
Showing 9 changed files with 46 additions and 17 deletions.
16 changes: 7 additions & 9 deletions src/core/frames/frame_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { FrameElement, FrameElementDelegate, FrameLoadingStyle } from "../../ele
import { FetchMethod, FetchRequest, FetchRequestDelegate, FetchRequestHeaders } from "../../http/fetch_request"
import { FetchResponse } from "../../http/fetch_response"
import { AppearanceObserver, AppearanceObserverDelegate } from "../../observers/appearance_observer"
import { getAttribute, parseHTMLDocument } from "../../util"
import { clearBusyState, getAttribute, parseHTMLDocument, markAsBusy } from "../../util"
import { FormSubmission, FormSubmissionDelegate } from "../drive/form_submission"
import { Snapshot } from "../snapshot"
import { ViewDelegate } from "../view"
Expand Down Expand Up @@ -165,7 +165,7 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
}

requestStarted(request: FetchRequest) {
this.element.setAttribute("busy", "")
[ this.element, document.documentElement ].forEach(markAsBusy)
}

requestPreventedHandlingResponse(request: FetchRequest, response: FetchResponse) {
Expand All @@ -188,14 +188,13 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
}

requestFinished(request: FetchRequest) {
this.element.removeAttribute("busy")
[ this.element, document.documentElement ].forEach(clearBusyState)
}

// Form submission delegate

formSubmissionStarted(formSubmission: FormSubmission) {
const frame = this.findFrameElement(formSubmission.formElement)
frame.setAttribute("busy", "")
formSubmissionStarted({ formElement }: FormSubmission) {
[ formElement, this.findFrameElement(formElement), document.documentElement ].forEach(markAsBusy)
}

formSubmissionSucceededWithResponse(formSubmission: FormSubmission, response: FetchResponse) {
Expand All @@ -214,9 +213,8 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
console.error(error)
}

formSubmissionFinished(formSubmission: FormSubmission) {
const frame = this.findFrameElement(formSubmission.formElement)
frame.removeAttribute("busy")
formSubmissionFinished({ formElement }: FormSubmission) {
[ formElement, this.findFrameElement(formElement), document.documentElement ].forEach(clearBusyState)
}

// View delegate
Expand Down
4 changes: 3 additions & 1 deletion src/core/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { ScrollObserver } from "../observers/scroll_observer"
import { StreamMessage } from "./streams/stream_message"
import { StreamObserver } from "../observers/stream_observer"
import { Action, Position, StreamSource, isAction } from "./types"
import { dispatch } from "../util"
import { clearBusyState, dispatch, markAsBusy } from "../util"
import { PageView, PageViewDelegate } from "./drive/page_view"
import { Visit, VisitOptions } from "./drive/visit"
import { PageSnapshot } from "./drive/page_snapshot"
Expand Down Expand Up @@ -285,6 +285,7 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin
}

notifyApplicationAfterVisitingLocation(location: URL, action: Action) {
markAsBusy(document.documentElement)
return dispatch("turbo:visit", { detail: { url: location.href, action } })
}

Expand All @@ -301,6 +302,7 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin
}

notifyApplicationAfterPageLoad(timing: TimingData = {}) {
clearBusyState(document.documentElement)
return dispatch("turbo:load", { detail: { url: this.location.href, timing }})
}

Expand Down
2 changes: 1 addition & 1 deletion src/tests/fixtures/form.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html id="html">
<head>
<meta charset="utf-8">
<title>Form</title>
Expand Down
2 changes: 1 addition & 1 deletion src/tests/fixtures/frames.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html id="html">
<head>
<meta charset="utf-8">
<title>Frame</title>
Expand Down
2 changes: 1 addition & 1 deletion src/tests/fixtures/navigation.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html id="html">
<head>
<meta charset="utf-8">
<title>Turbo</title>
Expand Down
12 changes: 10 additions & 2 deletions src/tests/functional/form_submission_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export class FormSubmissionTests extends TurboDriveTestCase {
this.assert.equal(await this.pathname, "/src/tests/fixtures/form.html")
this.assert.equal(await this.visitAction, "advance")
this.assert.equal(await this.getSearchParam("greeting"), "Hello from a redirect")
this.assert.equal(await this.nextAttributeMutationNamed("html", "aria-busy"), "true", "sets [aria-busy] on the document element")
this.assert.equal(await this.nextAttributeMutationNamed("html", "aria-busy"), null, "removes [aria-busy] from the document element")
}

async "test standard POST form submission events"() {
Expand Down Expand Up @@ -431,23 +433,29 @@ export class FormSubmissionTests extends TurboDriveTestCase {
this.assert.equal(await this.attributeForSelector("#frame", "src"), url.href, "redirects the target frame")
}

async "test frame form submission toggles the ancestor frame's [busy] attribute"() {
async "test frame form submission toggles the ancestor frame's [aria-busy] attribute"() {
await this.clickSelector("#frame form.redirect input[type=submit]")
await this.nextBeat

this.assert.equal(await this.nextAttributeMutationNamed("frame", "busy"), "", "sets [busy] on the #frame")
this.assert.equal(await this.nextAttributeMutationNamed("frame", "aria-busy"), "true", "sets [aria-busy] on the #frame")
this.assert.equal(await this.nextAttributeMutationNamed("html", "aria-busy"), "true", "sets [aria-busy] on the document element")
this.assert.equal(await this.nextAttributeMutationNamed("frame", "busy"), null, "removes [busy] from the #frame")
this.assert.equal(await this.nextAttributeMutationNamed("frame", "aria-busy"), null, "removes [aria-busy] from the #frame")
this.assert.equal(await this.nextAttributeMutationNamed("html", "aria-busy"), null, "removes [aria-busy] from the document element")
}

async "test frame form submission toggles the target frame's [busy] attribute"() {
async "test frame form submission toggles the target frame's [aria-busy] attribute"() {
await this.clickSelector('#targets-frame form.frame [type="submit"]')
await this.nextBeat

this.assert.equal(await this.nextAttributeMutationNamed("frame", "busy"), "", "sets [busy] on the #frame")
this.assert.equal(await this.nextAttributeMutationNamed("frame", "aria-busy"), "true", "sets [aria-busy] on the #frame")

const title = await this.querySelector("#frame h2")
this.assert.equal(await title.getVisibleText(), "Frame: Loaded")
this.assert.equal(await this.nextAttributeMutationNamed("frame", "busy"), null, "removes [busy] from the #frame")
this.assert.equal(await this.nextAttributeMutationNamed("frame", "aria-busy"), null, "removes [aria-busy] from the #frame")
}

async "test frame form submission with empty created response"() {
Expand Down
8 changes: 6 additions & 2 deletions src/tests/functional/frame_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,15 @@ export class FrameTests extends TurboDriveTestCase {
this.assert.equal(otherEvents.length, 0, "no more events")
}

async "test following a link driving a frame toggles the [busy] attribute"() {
async "test following a link driving a frame toggles the [aria-busy=true] attribute"() {
await this.clickSelector("#hello a")

this.assert.equal(await this.nextAttributeMutationNamed("frame", "busy"), "", "sets [busy] on the #frame")
this.assert.equal(await this.nextAttributeMutationNamed("frame", "busy"), null, "removes [busy] from the #frame")
this.assert.equal(await this.nextAttributeMutationNamed("frame", "aria-busy"), "true", "sets [aria-busy=true] on the #frame")
this.assert.equal(await this.nextAttributeMutationNamed("html", "aria-busy"), "true", "sets [aria-busy=true] on the document element")
this.assert.equal(await this.nextAttributeMutationNamed("frame", "busy"), null, "removes [busy] on the #frame")
this.assert.equal(await this.nextAttributeMutationNamed("frame", "aria-busy"), null, "removes [aria-busy] from the #frame")
this.assert.equal(await this.nextAttributeMutationNamed("html", "aria-busy"), null, "removes [aria-busy] from the document element")
}

async "test following a link to a page without a matching frame results in an empty frame"() {
Expand Down
2 changes: 2 additions & 0 deletions src/tests/functional/navigation_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export class NavigationTests extends TurboDriveTestCase {
await this.nextBody
this.assert.equal(await this.pathname, "/src/tests/fixtures/one.html")
this.assert.equal(await this.visitAction, "advance")
this.assert.equal(await this.nextAttributeMutationNamed("html", "aria-busy"), "true", "sets [aria-busy] on the document element")
this.assert.equal(await this.nextAttributeMutationNamed("html", "aria-busy"), null, "removes [aria-busy] from the document element")
}

async "test following a same-origin unannotated custom element link"() {
Expand Down
15 changes: 15 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,18 @@ export function getAttribute(attributeName: string, ...elements: (Element | unde

return null
}

export function markAsBusy(element: Element) {
if (element.localName == "turbo-frame") {
element.setAttribute("busy", "")
}
element.setAttribute("aria-busy", "true")
}

export function clearBusyState(element: Element) {
if (element.localName == "turbo-frame") {
element.removeAttribute("busy")
}

element.removeAttribute("aria-busy")
}

0 comments on commit 4a13b6d

Please sign in to comment.