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: #48.
  • Loading branch information
inopinatus committed May 25, 2021
1 parent aae03ad commit f443b73
Show file tree
Hide file tree
Showing 12 changed files with 187 additions and 83 deletions.
Binary file added .DS_Store
Binary file not shown.
20 changes: 12 additions & 8 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, FetchRequestHeaders } from "../../http/fetch_request"
import { FetchResponse } from "../../http/fetch_response"
import { AppearanceObserver, AppearanceObserverDelegate } from "../../observers/appearance_observer"
Expand Down Expand Up @@ -251,21 +251,21 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
const id = CSS.escape(this.id)

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

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

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

return new FrameElement()
return new this.elementConstructor()
}

private shouldInterceptNavigation(element: Element, submitter?: Element) {
Expand Down Expand Up @@ -293,6 +293,10 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
return true
}

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

// Computed properties

get id() {
Expand Down Expand Up @@ -331,7 +335,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 @@ -341,13 +345,13 @@ function activateElement(element: Element | null, currentURL?: string) {
if (element) {
const src = element.getAttribute("src")
if (src != null && currentURL != null && urlsAreEqual(src, currentURL)) {
throw new Error(`Matching <turbo-frame id="${element.id}"> element has a source URL which references itself`)
throw new Error(`Matching <${element.tagName} id="${element.id}"> element has a source URL which references itself`)
}
if (element.ownerDocument !== document) {
element = document.importNode(element, true)
}

if (element instanceof FrameElement) {
if (isTurboFrameElement(element)) {
element.connectedCallback()
return element
}
Expand Down
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, FormSubmitObserverDelegate } 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 @@ -95,6 +96,10 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin
this.progressBarDelay = delay
}

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

get location() {
return this.history.location
}
Expand Down
188 changes: 119 additions & 69 deletions src/elements/frame_element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@ 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
connectedCallback(): void
disconnectedCallback(): void
attributeChangedCallback(name: string): void
}

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

export interface FrameElementDelegate {
connect(): void
disconnect(): void
Expand All @@ -13,97 +32,117 @@ 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 ["disabled", "loading", "src"]
}

static get observedAttributes() {
return ["disabled", "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()
} else {
this.delegate.disabledChanged()
}
}

attributeChangedCallback(name: string) {
if (name == "loading") {
this.delegate.loadingStyleChanged()
} else if (name == "src") {
this.delegate.sourceURLChanged()
} else {
this.delegate.disabledChanged()
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")
}
}
}

Expand All @@ -113,3 +152,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 })
}
9 changes: 9 additions & 0 deletions src/tests/fixtures/frames.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,14 @@ <h2>Frames: #nested-child</h2>
</turbo-frame>

<a id="frame-self" href="/src/tests/fixtures/frames/self.html" data-turbo-frame="frame">Visit self.html</a>

<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>
Loading

0 comments on commit f443b73

Please sign in to comment.