Skip to content

Commit

Permalink
Turbo Frames, remixed.
Browse files Browse the repository at this point in the history
This PR abstracts the FrameElement class into an anonymous class
factory and matching interface type, permitting mixin to (almost)
any element, and enabling registration of customized built-in
elements as frames in addition to the standard autonomous custom
element.

Supports treating elements as a Turbo Frame that cannot otherwise
carry one due to their content model, such as <tbody>.

Set up with:

    Turbo.defineCustomFrameElement('tbody')

and then use like:

    <table>
      <tbody id="tbody" is="turbo-frame-tbody">
        <tr><td>Table content</td></tr>
      </tbody>
    </table>

The response frame must match by the element name and both the is
and id attributes.

Implements: hotwired#48.
  • Loading branch information
inopinatus committed Jan 23, 2021
1 parent f9d1651 commit 372c7a3
Show file tree
Hide file tree
Showing 10 changed files with 181 additions and 81 deletions.
18 changes: 11 additions & 7 deletions src/core/frames/frame_controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FrameElement, FrameElementDelegate, FrameLoadingStyle } from "../../elements/frame_element"
import { FrameElement, FrameElementDelegate, FrameLoadingStyle, isTurboFrameElement } from "../../elements/frame_element"
import { FetchMethod, FetchRequest, FetchRequestDelegate } from "../../http/fetch_request"
import { FetchResponse } from "../../http/fetch_response"
import { AppearanceObserver, AppearanceObserverDelegate } from "../../observers/appearance_observer"
Expand Down Expand Up @@ -197,17 +197,17 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
let element
const id = CSS.escape(this.id)

if (element = activateElement(container.querySelector(`turbo-frame#${id}`))) {
if (element = activateElement(container.querySelector(`${this.element.selector}#${id}`))) {
return element
}

if (element = activateElement(container.querySelector(`turbo-frame[src][recurse~=${id}]`))) {
if (element = activateElement(container.querySelector(`${this.element.selector}[src][recurse~=${id}]`))) {
await element.loaded
return await this.extractForeignFrameElement(element)
}

console.error(`Response has no matching <turbo-frame id="${id}"> element`)
return new FrameElement()
console.error(`Response has no element matching ${this.element.selector}#${id}`)
return new this.elementConstructor()
}

private loadFrameElement(frameElement: FrameElement) {
Expand Down Expand Up @@ -261,6 +261,10 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
return true
}

private get elementConstructor() {
return Object.getPrototypeOf(this.element).constructor
}

// Computed properties

get firstAutofocusableElement(): HTMLElement | null {
Expand Down Expand Up @@ -296,7 +300,7 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
function getFrameElementById(id: string | null) {
if (id != null) {
const element = document.getElementById(id)
if (element instanceof FrameElement) {
if (isTurboFrameElement(element)) {
return element
}
}
Expand All @@ -322,7 +326,7 @@ function activateElement(element: Node | null) {
element = document.importNode(element, true)
}

if (element instanceof FrameElement) {
if (isTurboFrameElement(element)) {
return element
}
}
6 changes: 3 additions & 3 deletions src/core/frames/frame_redirector.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FormInterceptor, FormInterceptorDelegate } from "./form_interceptor"
import { FrameElement } from "../../elements/frame_element"
import { isTurboFrameElement } from "../../elements/frame_element"
import { LinkInterceptor, LinkInterceptorDelegate } from "./link_interceptor"

export class FrameRedirector implements LinkInterceptorDelegate, FormInterceptorDelegate {
Expand Down Expand Up @@ -47,14 +47,14 @@ export class FrameRedirector implements LinkInterceptorDelegate, FormInterceptor

private shouldRedirect(element: Element, submitter?: HTMLElement) {
const frame = this.findFrameElement(element)
return frame ? frame != element.closest("turbo-frame") : false
return frame ? frame != element.closest(`turbo-frame, [is^="turbo-frame-"]`) : false
}

private findFrameElement(element: Element) {
const id = element.getAttribute("data-turbo-frame")
if (id && id != "_top") {
const frame = this.element.querySelector(`#${id}:not([disabled])`)
if (frame instanceof FrameElement) {
if (isTurboFrameElement(frame)) {
return frame
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/core/frames/link_interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,6 @@ export class LinkInterceptor {
: target instanceof Node
? target.parentElement
: null
return element && element.closest("turbo-frame, html") == this.element
return element && element.closest(`turbo-frame, [is^="turbo-frame-"], html`) == this.element
}
}
4 changes: 4 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,7 @@ export function clearCache() {
export function setProgressBarDelay(delay: number) {
session.setProgressBarDelay(delay)
}

export function defineCustomFrameElement(name: string) {
session.defineCustomFrameElement(name)
}
5 changes: 5 additions & 0 deletions src/core/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Adapter } from "./native/adapter"
import { BrowserAdapter } from "./native/browser_adapter"
import { FormSubmitObserver } from "../observers/form_submit_observer"
import { FrameRedirector } from "./frames/frame_redirector"
import { defineCustomFrameElement } from "../elements"
import { History, HistoryDelegate } from "./drive/history"
import { LinkClickObserver, LinkClickObserverDelegate } from "../observers/link_click_observer"
import { expandURL, isPrefixedBy, isHTML, Locatable } from "./url"
Expand Down Expand Up @@ -94,6 +95,10 @@ export class Session implements HistoryDelegate, LinkClickObserverDelegate, Navi
this.progressBarDelay = delay
}

defineCustomFrameElement(name: string) {
defineCustomFrameElement(name)
}

get location() {
return this.history.location
}
Expand Down
183 changes: 115 additions & 68 deletions src/elements/frame_element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@ import { FetchResponse } from "../http/fetch_response"

export enum FrameLoadingStyle { eager = "eager", lazy = "lazy" }

export interface FrameElement extends HTMLElement {
isTurboFrameElement: boolean
selector: string
delegate: FrameElementDelegate
loaded: Promise<FetchResponse | void>
src: string | null
disabled: boolean
loading: string
isActive: boolean
autoscroll: boolean
}

export namespace FrameElement {
export let delegateConstructor: new (element: FrameElement) => FrameElementDelegate
}

export interface FrameElementDelegate {
connect(): void
disconnect(): void
Expand All @@ -12,96 +28,116 @@ export interface FrameElementDelegate {
isLoading: boolean
}

export class FrameElement extends HTMLElement {
static delegateConstructor: new (element: FrameElement) => FrameElementDelegate
export function frameElementFactory(Base: new() => HTMLElement) {
return class extends Base implements FrameElement {
readonly isTurboFrameElement: boolean = true
loaded: Promise<FetchResponse | void> = Promise.resolve()
readonly delegate: FrameElementDelegate

loaded: Promise<FetchResponse | void> = Promise.resolve()
readonly delegate: FrameElementDelegate
static get observedAttributes() {
return ["loading", "src"]
}

static get observedAttributes() {
return ["loading", "src"]
}
constructor() {
super()
if (!this.autonomous) {
this.setAttribute("is", this.isValue)
}
this.delegate = new FrameElement.delegateConstructor(this)
}

constructor() {
super()
this.delegate = new FrameElement.delegateConstructor(this)
}
connectedCallback() {
this.delegate.connect()
}

connectedCallback() {
this.delegate.connect()
}
disconnectedCallback() {
this.delegate.disconnect()
}

disconnectedCallback() {
this.delegate.disconnect()
}
attributeChangedCallback(name: string) {
if (name == "loading") {
this.delegate.loadingStyleChanged()
} else if (name == "src") {
this.delegate.sourceURLChanged()
}
}

attributeChangedCallback(name: string) {
if (name == "loading") {
this.delegate.loadingStyleChanged()
} else if (name == "src") {
this.delegate.sourceURLChanged()
get selector(): string {
if (this.autonomous) {
return this.localName
} else {
return `${this.localName}[is="${this.isValue}"]`
}
}
}

get src() {
return this.getAttribute("src")
}
get isValue(): string {
return `turbo-frame-${this.localName}`
}

set src(value: string | null) {
if (value) {
this.setAttribute("src", value)
} else {
this.removeAttribute("src")
get autonomous(): boolean {
return Base === HTMLElement
}
}

get loading(): FrameLoadingStyle {
return frameLoadingStyleFromString(this.getAttribute("loading") || "")
}
get src() {
return this.getAttribute("src")
}

set loading(value: FrameLoadingStyle) {
if (value) {
this.setAttribute("loading", value)
} else {
this.removeAttribute("loading")
set src(value: string | null) {
if (value) {
this.setAttribute("src", value)
} else {
this.removeAttribute("src")
}
}
}

get disabled() {
return this.hasAttribute("disabled")
}
get loading(): FrameLoadingStyle {
return frameLoadingStyleFromString(this.getAttribute("loading") || "")
}

set disabled(value: boolean) {
if (value) {
this.setAttribute("disabled", "")
} else {
this.removeAttribute("disabled")
set loading(value: FrameLoadingStyle) {
if (value) {
this.setAttribute("loading", value)
} else {
this.removeAttribute("loading")
}
}
}

get autoscroll() {
return this.hasAttribute("autoscroll")
}
get disabled() {
return this.hasAttribute("disabled")
}

set autoscroll(value: boolean) {
if (value) {
this.setAttribute("autoscroll", "")
} else {
this.removeAttribute("autoscroll")
set disabled(value: boolean) {
if (value) {
this.setAttribute("disabled", "")
} else {
this.removeAttribute("disabled")
}
}
}

get complete() {
return !this.delegate.isLoading
}
get autoscroll() {
return this.hasAttribute("autoscroll")
}

get isActive() {
return this.ownerDocument === document && !this.isPreview
}
set autoscroll(value: boolean) {
if (value) {
this.setAttribute("autoscroll", "")
} else {
this.removeAttribute("autoscroll")
}
}

get isPreview() {
return this.ownerDocument?.documentElement?.hasAttribute("data-turbo-preview")
}
get complete() {
return !this.delegate.isLoading
}

get isActive() {
return this.ownerDocument === document && !this.isPreview
}

get isPreview() {
return this.ownerDocument?.documentElement?.hasAttribute("data-turbo-preview")
}
};
}

function frameLoadingStyleFromString(style: string) {
Expand All @@ -110,3 +146,14 @@ function frameLoadingStyleFromString(style: string) {
default: return FrameLoadingStyle.eager
}
}

export function builtinTurboFrameElement(name: string) {
const baseElementConstructor = Object.getPrototypeOf(document.createElement(name)).constructor
return frameElementFactory(baseElementConstructor)
}

export function isTurboFrameElement(arg: any): arg is FrameElement {
return arg && arg.isTurboFrameElement && arg instanceof HTMLElement
}

export const TurboFrameElement = frameElementFactory(HTMLElement)
8 changes: 6 additions & 2 deletions src/elements/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { FrameController } from "../core/frames/frame_controller"
import { FrameElement } from "./frame_element"
import { FrameElement, TurboFrameElement, builtinTurboFrameElement } from "./frame_element"
import { StreamElement } from "./stream_element"

FrameElement.delegateConstructor = FrameController

export * from "./frame_element"
export * from "./stream_element"

customElements.define("turbo-frame", FrameElement)
customElements.define("turbo-frame", TurboFrameElement)
customElements.define("turbo-stream", StreamElement)

export function defineCustomFrameElement(name: string) {
customElements.define(`turbo-frame-${name}`, builtinTurboFrameElement(name), { extends: name })
}
10 changes: 10 additions & 0 deletions src/tests/fixtures/frames.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<meta charset="utf-8">
<title>Frame</title>
<script src="/dist/turbo.es2017-umd.js" data-turbo-track="reload"></script>
<script>Turbo.defineCustomFrameElement('tbody')</script>
</head>
<body>
<h1>Frames</h1>
Expand All @@ -19,5 +20,14 @@ <h2>Frames: #hello</h2>
<turbo-frame id="missing">
<a href="/src/tests/fixtures/frames/frame.html">Missing frame</a>
</turbo-frame>

<table>
<thead id="thead0">
<tr><th>table thead0</th></tr>
</thead>
<tbody id="tbody0" is="turbo-frame-tbody">
<tr><td><a href="/src/tests/fixtures/frames/table.html">Set table</a></td></tr>
</tbody>
</table>
</body>
</html>
15 changes: 15 additions & 0 deletions src/tests/fixtures/frames/table.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Frame</title>
<script src="/dist/turbo.es2017-umd.js" data-turbo-track="reload"></script>
</head>
<body>
<table>
<tbody id="tbody0" is="turbo-frame-tbody">
<tr><td>Table service</td></tr>
</tbody>
</table>
</body>
</html>
Loading

0 comments on commit 372c7a3

Please sign in to comment.