Skip to content

Commit

Permalink
Render progress bar during form submissions
Browse files Browse the repository at this point in the history
Closes hotwired#61
Closes hotwired#147

---

Extends the `Adapter` interface to support new
`formSubmissionStarted(FormSubmission)` and
`formSubmissionFinished(FormSubmission)` delegate methods to hook into a
page's form submission lifecycle. Making the Adapter aware of form
submissions provides an opportunity to show and hide the progress bar in
the same way as driving the page with Visits.

Additionally, replace the `HTMLDivElement` implementation of the
progress bar with a [progress][] element and an instance of
[HTMLProgressElement][]. When updating the progress, set the
`HTMLProgressElement.value` attribute, and let the browser-native
implementation update its width. Since the element's `value` attribute
is accessible via a property, we no longer have to synchronize via
`requestAnimationFrame()`.

To support the built-in element, there are some proprietary
pseudoelements to consider across the browsers, including:

* [-moz-progress-bar][] for the Firefox bar
* [-webkit-progress-bar][] for the Safari and Chrome bar background
* [-webkit-progress-value][] for the Safari and Chrome bar progress
* [-ms-fill][] for IE bars

[progress]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/progress
[HTMLProgressElement]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLProgressElement
[-moz-progress-bar]: https://developer.mozilla.org/en-US/docs/Web/CSS/::-moz-progress-bar
[-webkit-progress-bar]: https://developer.mozilla.org/en-US/docs/Web/CSS/::-webkit-progress-bar
[-webkit-progress-value]: https://developer.mozilla.org/en-US/docs/Web/CSS/::-webkit-progress-value
[-ms-fill]: https://developer.mozilla.org/en-US/docs/Archive/Web/CSS/::-ms-fill
  • Loading branch information
seanpdoyle committed Feb 7, 2021
1 parent 57a118e commit 76e2307
Show file tree
Hide file tree
Showing 10 changed files with 114 additions and 28 deletions.
4 changes: 2 additions & 2 deletions src/core/drive/navigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export class Navigator {
// Form submission delegate

formSubmissionStarted(formSubmission: FormSubmission) {

this.adapter.formSubmissionStarted(formSubmission)
}

async formSubmissionSucceededWithResponse(formSubmission: FormSubmission, fetchResponse: FetchResponse) {
Expand Down Expand Up @@ -105,7 +105,7 @@ export class Navigator {
}

formSubmissionFinished(formSubmission: FormSubmission) {

this.adapter.formSubmissionFinished(formSubmission)
}

// Visit delegate
Expand Down
52 changes: 32 additions & 20 deletions src/core/drive/progress_bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { unindent } from "../../util"

export class ProgressBar {
static animationDuration = 300/*ms*/
static color = "#0076ff"

static get defaultCSS() {
return unindent`
Expand All @@ -11,29 +12,44 @@ export class ProgressBar {
top: 0;
left: 0;
height: 3px;
background: #0076ff;
background: transparent;
z-index: 9999;
transition:
width ${ProgressBar.animationDuration}ms ease-out,
opacity ${ProgressBar.animationDuration / 2}ms ${ProgressBar.animationDuration / 2}ms ease-in;
transform: translate3d(0, 0, 0);
width: 100%;
-webkit-appearance: none;
}
.turbo-progress-bar::-ms-fill {
background: ${ProgressBar.color};
transition: width ${ProgressBar.animationDuration}ms ease-out;
}
.turbo-progress-bar::::-moz-progress-bar {
background: ${ProgressBar.color};
transition: width ${ProgressBar.animationDuration}ms ease-out;
}
.turbo-progress-bar::-webkit-progress-bar {
background: transparent;
}
.turbo-progress-bar::-webkit-progress-value {
background: ${ProgressBar.color};
transition: width ${ProgressBar.animationDuration}ms ease-out;
}
`
}

readonly stylesheetElement: HTMLStyleElement
readonly progressElement: HTMLDivElement
readonly progressElement: HTMLProgressElement

hiding = false
trickleInterval?: number
value = 0
visible = false

constructor() {
this.stylesheetElement = this.createStylesheetElement()
this.progressElement = this.createProgressElement()
this.installStylesheetElement()
this.setValue(0)
this.value = 0
}

show() {
Expand All @@ -56,9 +72,12 @@ export class ProgressBar {
}
}

setValue(value: number) {
this.value = value
this.refresh()
get value(): number {
return this.progressElement.value
}

set value(value: number) {
this.progressElement.value = Math.min(value, 1.0)
}

// Private
Expand All @@ -68,10 +87,9 @@ export class ProgressBar {
}

installProgressElement() {
this.progressElement.style.width = "0"
this.value = 0
this.progressElement.style.opacity = "1"
document.documentElement.insertBefore(this.progressElement, document.body)
this.refresh()
}

fadeProgressElement(callback: () => void) {
Expand All @@ -80,8 +98,8 @@ export class ProgressBar {
}

uninstallProgressElement() {
if (this.progressElement.parentNode) {
document.documentElement.removeChild(this.progressElement)
if (this.progressElement.isConnected) {
this.progressElement.remove()
}
}

Expand All @@ -97,13 +115,7 @@ export class ProgressBar {
}

trickle = () => {
this.setValue(this.value + Math.random() / 100)
}

refresh() {
requestAnimationFrame(() => {
this.progressElement.style.width = `${10 + (this.value * 90)}%`
})
this.value = this.value + (Math.random() / 100)
}

createStylesheetElement() {
Expand All @@ -114,7 +126,7 @@ export class ProgressBar {
}

createProgressElement() {
const element = document.createElement("div")
const element = document.createElement("progress")
element.className = "turbo-progress-bar"
return element
}
Expand Down
3 changes: 3 additions & 0 deletions src/core/native/adapter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Visit, VisitOptions } from "../drive/visit"
import { FormSubmission } from "../drive/form_submission"

export interface Adapter {
visitProposedToLocation(location: URL, options?: Partial<VisitOptions>): void
Expand All @@ -10,5 +11,7 @@ export interface Adapter {
visitRequestFailedWithStatusCode(visit: Visit, statusCode: number): void
visitRequestFinished(visit: Visit): void
visitRendered(visit: Visit): void
formSubmissionStarted(formSubmission: FormSubmission): void
formSubmissionFinished(formSubmission: FormSubmission): void
pageInvalidated(): void
}
19 changes: 16 additions & 3 deletions src/core/native/browser_adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ProgressBar } from "../drive/progress_bar"
import { SystemStatusCode, Visit, VisitOptions } from "../drive/visit"
import { Session } from "../session"
import { uuid } from "../../util"
import { FormSubmission } from "../drive/form_submission"

export class BrowserAdapter implements Adapter {
readonly session: Session
Expand All @@ -25,7 +26,7 @@ export class BrowserAdapter implements Adapter {
}

visitRequestStarted(visit: Visit) {
this.progressBar.setValue(0)
this.progressBar.value = 0
if (visit.hasCachedSnapshot() || visit.action != "restore") {
this.showProgressBarAfterDelay()
} else {
Expand All @@ -49,7 +50,7 @@ export class BrowserAdapter implements Adapter {
}

visitRequestFinished(visit: Visit) {
this.progressBar.setValue(1)
this.progressBar.value = 1.0
this.hideProgressBar()
}

Expand All @@ -69,14 +70,26 @@ export class BrowserAdapter implements Adapter {

}

formSubmissionStarted(formSubmission: FormSubmission) {
this.progressBar.value = 0
this.showProgressBarAfterDelay()
}

formSubmissionFinished(formSubmission: FormSubmission) {
this.progressBar.value = 1.0
this.hideProgressBar()
}

// Private

showProgressBarAfterDelay() {
this.progressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay)
}

showProgressBar = () => {
this.progressBar.show()
if (this.progressBar.value < 1.0) {
this.progressBar.show()
}
}

hideProgressBar() {
Expand Down
5 changes: 5 additions & 0 deletions src/tests/fixtures/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@
<input type="hidden" name="query" value="2">
<input type="submit">
</form>
<form action="/__turbo/redirect" method="post" class="sleep">
<input type="hidden" name="path" value="/src/tests/fixtures/form.html">
<input type="hidden" name="sleep" value="500">
<input type="submit">
</form>
</div>
<hr>
<div id="no-action">
Expand Down
20 changes: 20 additions & 0 deletions src/tests/functional/form_submission_tests.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { TurboDriveTestCase } from "../helpers/turbo_drive_test_case"
import { ProgressBar } from "../../core/drive/progress_bar"

export class FormSubmissionTests extends TurboDriveTestCase {
async setup() {
Expand All @@ -8,6 +9,25 @@ export class FormSubmissionTests extends TurboDriveTestCase {
})
}

async "test standard form submission renders a progress bar"() {
await this.remote.execute(() => window.Turbo.setProgressBarDelay(0))
await this.clickSelector("#standard form.sleep input[type=submit]")

this.assert.ok(await this.hasSelector("progress[value]"), "displays progress bar")

await this.nextBody
await this.wait(ProgressBar.animationDuration + 200)

this.assert.notOk(await this.hasSelector("progress"), "hides progress bar")
}

async "test standard form submission does not render a progress bar before expiring the delay"() {
await this.remote.execute(() => window.Turbo.setProgressBarDelay(500))
await this.clickSelector("#standard form.redirect input[type=submit]")

this.assert.notOk(await this.hasSelector("progress"), "does not show progress bar before delay")
}

async "test standard form submission with redirect response"() {
await this.clickSelector("#standard form.redirect input[type=submit]")
await this.nextBody
Expand Down
20 changes: 20 additions & 0 deletions src/tests/functional/navigation_tests.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
import { TurboDriveTestCase } from "../helpers/turbo_drive_test_case"
import { ProgressBar } from "../../core/drive/progress_bar"

export class NavigationTests extends TurboDriveTestCase {
async setup() {
await this.goToLocation("/src/tests/fixtures/navigation.html")
}

async "test navigating renders a progress bar"() {
await this.remote.execute(() => window.Turbo.setProgressBarDelay(0))
await this.clickSelector("a:first-of-type")

this.assert.ok(await this.hasSelector("progress[value]"), "displays progress bar")

await this.nextBody
await this.wait(ProgressBar.animationDuration + 200)

this.assert.notOk(await this.hasSelector("progress"), "hides progress bar")
}

async "test navigating does not render a progress bar before expiring the delay"() {
await this.remote.execute(() => window.Turbo.setProgressBarDelay(1000))
await this.clickSelector("a:first-of-type")

this.assert.notOk(await this.hasSelector("progress"), "does not show progress bar before delay")
}

async "test after loading the page"() {
this.assert.equal(await this.pathname, "/src/tests/fixtures/navigation.html")
this.assert.equal(await this.visitAction, "load")
Expand Down
6 changes: 5 additions & 1 deletion src/tests/helpers/functional_test_case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,12 @@ export class FunctionalTestCase extends InternTestCase {
return offset > -1 && offset < 1
}

wait(milliseconds: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, milliseconds))
}

get nextBeat(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 100))
return this.wait(100)
}

async evaluate<T>(callback: (...args: any[]) => T, ...args: any[]): Promise<T> {
Expand Down
4 changes: 2 additions & 2 deletions src/tests/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ router.use((request, response, next) => {
})

router.post("/redirect", (request, response) => {
const { path, ...query } = request.body
const { path, sleep, ...query } = request.body
const pathname = path ?? "/src/tests/fixtures/one.html"
const enctype = request.get("Content-Type")
if (enctype) {
query.enctype = enctype
}
response.redirect(303, url.format({ pathname, query }))
setTimeout(() => response.redirect(303, url.format({ pathname, query })), parseInt(sleep || "0", 10))
})

router.get("/redirect", (request, response) => {
Expand Down
9 changes: 9 additions & 0 deletions src/tests/unit/deprecated_adapter_support_test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { VisitOptions, Visit } from "../../core/drive/visit"
import { FormSubmission } from "../../core/drive/form_submission"
import { Adapter } from "../../core/native/adapter"
import * as Turbo from "../../index"
import { DOMTestCase } from "../helpers/dom_test_case"
Expand Down Expand Up @@ -70,6 +71,14 @@ export class DeprecatedAdapterSupportTest extends DOMTestCase implements Adapter

}

formSubmissionStarted(formSubmission: FormSubmission): void {

}

formSubmissionFinished(formSubmission: FormSubmission): void {

}

pageInvalidated(): void {

}
Expand Down

0 comments on commit 76e2307

Please sign in to comment.