diff --git a/src/core/streams/actions/morph.js b/src/core/streams/actions/morph.js new file mode 100644 index 000000000..d177bfd01 --- /dev/null +++ b/src/core/streams/actions/morph.js @@ -0,0 +1,63 @@ +import { Idiomorph } from "idiomorph/dist/idiomorph.esm" +import { dispatch } from "../../../util" + +export default function morph(streamElement) { + const morphStyle = streamElement.hasAttribute("children-only") ? "innerHTML" : "outerHTML" + streamElement.targetElements.forEach((element) => { + Idiomorph.morph(element, streamElement.templateContent, { + morphStyle: morphStyle, + callbacks: { + beforeNodeAdded, + beforeNodeMorphed, + beforeAttributeUpdated, + beforeNodeRemoved, + afterNodeMorphed + } + }) + }) +} + +function beforeNodeAdded(node) { + return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) +} + +function beforeNodeRemoved(node) { + return beforeNodeAdded(node) +} + +function beforeNodeMorphed(target, newElement) { + if (target instanceof HTMLElement && !target.hasAttribute("data-turbo-permanent")) { + const event = dispatch("turbo:before-morph-element", { + cancelable: true, + target, + detail: { + newElement + } + }) + return !event.defaultPrevented + } + return false +} + +function beforeAttributeUpdated(attributeName, target, mutationType) { + const event = dispatch("turbo:before-morph-attribute", { + cancelable: true, + target, + detail: { + attributeName, + mutationType + } + }) + return !event.defaultPrevented +} + +function afterNodeMorphed(target, newElement) { + if (newElement instanceof HTMLElement) { + dispatch("turbo:morph-element", { + target, + detail: { + newElement + } + }) + } +} diff --git a/src/core/streams/stream_actions.js b/src/core/streams/stream_actions.js index 064e94ca4..486dc8566 100644 --- a/src/core/streams/stream_actions.js +++ b/src/core/streams/stream_actions.js @@ -1,4 +1,5 @@ import { session } from "../" +import morph from "./actions/morph" export const StreamActions = { after() { @@ -36,5 +37,9 @@ export const StreamActions = { refresh() { session.refresh(this.baseURI, this.requestId) + }, + + morph() { + morph(this) } } diff --git a/src/tests/fixtures/morph_stream_action.html b/src/tests/fixtures/morph_stream_action.html new file mode 100644 index 000000000..df91274f5 --- /dev/null +++ b/src/tests/fixtures/morph_stream_action.html @@ -0,0 +1,16 @@ + + + + + Morph Stream Action + + + + + + +
+
Morph me
+
+ + diff --git a/src/tests/functional/morph_stream_action_tests.js b/src/tests/functional/morph_stream_action_tests.js new file mode 100644 index 000000000..b4f04c9d7 --- /dev/null +++ b/src/tests/functional/morph_stream_action_tests.js @@ -0,0 +1,48 @@ +import { test, expect } from "@playwright/test" +import { nextEventOnTarget, noNextEventOnTarget } from "../helpers/page" + +test("dispatches a turbo:before-morph-element & turbo:morph-element for each morph stream action", async ({ page }) => { + await page.goto("/src/tests/fixtures/morph_stream_action.html") + + await page.evaluate(() => { + window.Turbo.renderStreamMessage(` + + + + `) + }) + + await nextEventOnTarget(page, "message_1", "turbo:before-morph-element") + await nextEventOnTarget(page, "message_1", "turbo:morph-element") + await expect(page.locator("#message_1")).toHaveText("Morphed") +}) + +test("preventing a turbo:before-morph-element prevents the morph", async ({ page }) => { + await page.goto("/src/tests/fixtures/morph_stream_action.html") + + await page.evaluate(() => { + addEventListener("turbo:before-morph-element", (event) => { + event.preventDefault() + }) + }) + + await page.evaluate(() => { + window.Turbo.renderStreamMessage(` + + + + `) + }) + + await nextEventOnTarget(page, "message_1", "turbo:before-morph-element") + await noNextEventOnTarget(page, "message_1", "turbo:morph-element") + await expect(page.locator("#message_1")).toHaveText("Morph me") +}) diff --git a/src/tests/unit/stream_element_tests.js b/src/tests/unit/stream_element_tests.js index 21a9ca8aa..1e3b99f92 100644 --- a/src/tests/unit/stream_element_tests.js +++ b/src/tests/unit/stream_element_tests.js @@ -196,3 +196,32 @@ test("test action=refresh discarded when matching request id", async () => { assert.ok(document.body.hasAttribute("data-modified")) }) + +test("action=morph", async () => { + const templateElement = createTemplateElement(`

Hello Turbo Morphed

`) + const element = createStreamElement("morph", "hello", templateElement) + + assert.equal(subject.find("div#hello")?.textContent, "Hello Turbo") + + subject.append(element) + await nextAnimationFrame() + + assert.notOk(subject.find("div#hello")) + assert.equal(subject.find("h1#hello")?.textContent, "Hello Turbo Morphed") +}) + +test("action=morph children-only", async () => { + const templateElement = createTemplateElement(`

Hello Turbo Morphed

`) + const element = createStreamElement("morph", "hello", templateElement) + const target = subject.find("div#hello") + assert.equal(target?.textContent, "Hello Turbo") + element.setAttribute("children-only", true) + + subject.append(element) + + await nextAnimationFrame() + + assert.ok(subject.find("div#hello")) + assert.ok(subject.find("div#hello > h1#hello-child-element")) + assert.equal(subject.find("div#hello > h1#hello-child-element").textContent, "Hello Turbo Morphed") +})