Skip to content

Commit 6a2b293

Browse files
authored
Merge pull request #76 from koculu/r-teleport-on-detached-content
Add support r-teleport on detached content.
2 parents a643e6e + b17f209 commit 6a2b293

File tree

6 files changed

+214
-15
lines changed

6 files changed

+214
-15
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "regor",
3-
"version": "1.3.9",
3+
"version": "1.4.0",
44
"description": "A modern UI framework for web and desktop applications, inspired by Vue's concepts and powered by simplicity and flexibility.",
55
"author": "Ahmed Yasin Koculu",
66
"license": "MIT",

src/bind/Binder.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -141,21 +141,25 @@ export class Binder {
141141
): boolean {
142142
if (config !== teleportDirective) return false
143143
if (isNullOrWhitespace(valueExpression)) return true
144-
const teleportTo = document.querySelector(valueExpression)
145-
if (teleportTo) {
146-
const parent = el.parentElement
147-
if (!parent) return true
148-
const placeholder = new Comment(`teleported => '${valueExpression}'`)
149-
parent.insertBefore(placeholder, el)
150-
;(el as HTMLElement & { teleportedFrom: Node }).teleportedFrom =
151-
placeholder
152-
;(placeholder as Comment & { teleportedTo: HTMLElement }).teleportedTo =
153-
el
154-
addUnbinder(placeholder, () => {
155-
removeNode(el)
156-
})
157-
teleportTo.appendChild(el)
144+
let queryRoot = document as ParentNode
145+
if (!queryRoot) {
146+
let root = el.parentNode
147+
while (root?.parentNode) root = root.parentNode
148+
if (root) queryRoot = root
149+
else return true
158150
}
151+
const teleportTo = queryRoot.querySelector(valueExpression)
152+
if (!teleportTo) return true
153+
const parent = el.parentElement
154+
if (!parent) return true
155+
const placeholder = new Comment(`teleported => '${valueExpression}'`)
156+
parent.insertBefore(placeholder, el)
157+
;(el as HTMLElement & { teleportedFrom: Node }).teleportedFrom = placeholder
158+
;(placeholder as Comment & { teleportedTo: HTMLElement }).teleportedTo = el
159+
addUnbinder(placeholder, () => {
160+
removeNode(el)
161+
})
162+
teleportTo.appendChild(el)
159163
return true
160164
}
161165

tests/app/createComponent.spec.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Binder } from '../../src/bind/Binder'
1313
import { hasSwitch } from '../../src/bind/switch'
1414
import { Parser } from '../../src/parser/Parser'
1515
import { htmlEqual } from '../common/html-equal'
16+
import { createDom } from '../minidom/createDom'
1617

1718
test('should render components with reactive properties', () => {
1819
const root = document.createElement('div')
@@ -705,6 +706,142 @@ test('component inheritAttrs merges class and style from host onto inheritor roo
705706
expect(div.style.marginTop).toBe('5px')
706707
})
707708

709+
test('component attribute fallthrough can carry :r-teleport to component root', () => {
710+
const cleanup = createDom(
711+
'<html><body><div id="app"></div><div id="teleport-host"></div></body></html>',
712+
)
713+
try {
714+
const root = document.querySelector('#app') as HTMLElement
715+
const teleComp = defineComponent(
716+
html`<section class="tele-root">teleported</section>`,
717+
)
718+
719+
createApp(
720+
{
721+
components: { teleComp },
722+
target: '#teleport-host',
723+
},
724+
{
725+
element: root,
726+
template: html`<main>
727+
<TeleComp
728+
:r-teleport="target"
729+
class="from-host"
730+
data-origin="host"
731+
></TeleComp>
732+
</main>`,
733+
},
734+
)
735+
736+
const host = document.querySelector('#teleport-host') as HTMLElement
737+
const moved = host.querySelector('.tele-root') as HTMLElement | null
738+
expect(moved).toBeTruthy()
739+
expect(moved?.classList.contains('from-host')).toBe(true)
740+
expect(moved?.getAttribute('data-origin')).toBe('host')
741+
expect(root.innerHTML).toContain("teleported => '#teleport-host'")
742+
} finally {
743+
cleanup()
744+
}
745+
})
746+
747+
test('nested component tree teleports when parent host binds :r-teleport', () => {
748+
const cleanup = createDom(
749+
'<html><body><div id="app"></div><div id="teleport-host"></div></body></html>',
750+
)
751+
try {
752+
const root = document.querySelector('#app') as HTMLElement
753+
754+
const childComp = defineComponent(
755+
html`<span class="nested-child">nested payload</span>`,
756+
)
757+
const parentComp = defineComponent(
758+
html`<section class="parent-root" r-inherit>
759+
<ChildComp></ChildComp>
760+
</section>`,
761+
)
762+
763+
createApp(
764+
{
765+
components: { parentComp, childComp },
766+
target: '#teleport-host',
767+
},
768+
{
769+
element: root,
770+
template: html`<main>
771+
<ParentComp
772+
:r-teleport="target"
773+
class="from-parent-host"
774+
data-parent="yes"
775+
></ParentComp>
776+
</main>`,
777+
},
778+
)
779+
780+
const host = document.querySelector('#teleport-host') as HTMLElement
781+
const moved = host.querySelector('.parent-root') as HTMLElement | null
782+
const nested = host.querySelector('.nested-child') as HTMLElement | null
783+
784+
expect(moved).toBeTruthy()
785+
expect(moved?.classList.contains('from-parent-host')).toBe(true)
786+
expect(moved?.getAttribute('data-parent')).toBe('yes')
787+
expect(nested?.textContent?.trim()).toBe('nested payload')
788+
expect(root.innerHTML).toContain("teleported => '#teleport-host'")
789+
} finally {
790+
cleanup()
791+
}
792+
})
793+
794+
test('nested component tree teleports when parent binds :r-teleport on ChildComp', () => {
795+
const cleanup = createDom(
796+
'<html><body><div id="app"></div><div id="teleport-host"></div></body></html>',
797+
)
798+
try {
799+
const root = document.querySelector('#app') as HTMLElement
800+
801+
const childComp = defineComponent(
802+
html`<article class="child-root">
803+
<span class="inner-child">inner payload</span>
804+
</article>`,
805+
)
806+
const parentComp = defineComponent(
807+
html`<section class="parent-shell">
808+
<ChildComp
809+
:r-teleport="target"
810+
class="child-from-parent"
811+
data-from-parent="yes"
812+
></ChildComp>
813+
</section>`,
814+
{
815+
context: () => ({
816+
target: '#teleport-host',
817+
}),
818+
},
819+
)
820+
821+
createApp(
822+
{
823+
components: { parentComp, childComp },
824+
},
825+
{
826+
element: root,
827+
template: html`<main><ParentComp></ParentComp></main>`,
828+
},
829+
)
830+
const host = document.querySelector('#teleport-host') as HTMLElement
831+
const moved = host.querySelector('.child-root') as HTMLElement | null
832+
const nested = host.querySelector('.inner-child') as HTMLElement | null
833+
834+
expect(moved).toBeTruthy()
835+
expect(moved?.classList.contains('child-from-parent')).toBe(true)
836+
expect(moved?.getAttribute('data-from-parent')).toBe('yes')
837+
expect(nested?.textContent?.trim()).toBe('inner payload')
838+
expect(root.querySelector('.parent-shell')).toBeTruthy()
839+
expect(root.innerHTML).toContain("teleported => '#teleport-host'")
840+
} finally {
841+
cleanup()
842+
}
843+
})
844+
708845
test('component named slot fallback renders when slot content is not provided', () => {
709846
const root = document.createElement('div')
710847
const slotComp = defineComponent(

tests/bind/binder-edge.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,33 @@ test('binder teleport handler covers whitespace and missing parent/target paths'
147147
target.remove()
148148
}
149149
})
150+
151+
test('detached :r-teleport path falls back to parent-chain root when document lookup is unavailable at bind time', () => {
152+
const binder = createBinder()
153+
const root = document.createElement('div')
154+
const host = document.createElement('div')
155+
const el = document.createElement('div')
156+
const target = document.createElement('div')
157+
target.id = 'teleport-edge-missing-doc'
158+
root.appendChild(host)
159+
root.appendChild(target)
160+
host.appendChild(el)
161+
162+
const prevDocument = (globalThis as any).document
163+
try {
164+
;(globalThis as any).document = undefined
165+
expect(() => {
166+
binder.__bindToExpression(
167+
teleportDirective as any,
168+
el,
169+
'#teleport-edge-missing-doc',
170+
)
171+
}).not.toThrow()
172+
expect(target.contains(el)).toBe(true)
173+
expect(host.innerHTML).toContain(
174+
"teleported => '#teleport-edge-missing-doc'",
175+
)
176+
} finally {
177+
;(globalThis as any).document = prevDocument
178+
}
179+
})

tests/minidom/minidom.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,21 @@ describe('minidom templates', () => {
130130
})
131131

132132
describe('minidom parsing and serialization', () => {
133+
it('links document head and body from html documents', () =>
134+
withDom(
135+
'<html><head><meta charset="utf-8"></head><body><main>ok</main></body></html>',
136+
({ document }) => {
137+
expect(document.head?.querySelector('meta')).not.toBeNull()
138+
expect(document.body?.querySelector('main')?.textContent).toBe('ok')
139+
},
140+
))
141+
142+
it('links document head when document has head only', () =>
143+
withDom('<html><head><title>x</title></head></html>', ({ document }) => {
144+
expect(document.head?.querySelector('title')?.textContent).toBe('x')
145+
expect(document.body).toBeNull()
146+
}))
147+
133148
it('keeps raw text in script and style nodes', () =>
134149
withDom(
135150
'<html><body><script>if (a < b && c > d) { x = "&lt;raw&gt;" }</script></body></html>',

tests/minidom/minidom.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ class MiniNode {
307307

308308
class MiniDocument extends MiniNode {
309309
documentElement: MiniElement | null = null
310+
head: MiniElement | null = null
310311
body: MiniElement | null = null
311312

312313
constructor() {
@@ -373,14 +374,26 @@ class MiniDocument extends MiniNode {
373374
}
374375

375376
linkHtmlBody() {
377+
this.head = null
378+
this.body = null
376379
const html = this.querySelector('html')
377380
if (html && html instanceof MiniElement) {
378381
this.documentElement = html
382+
const head = html.querySelector('head')
383+
if (head && head instanceof MiniElement) {
384+
this.head = head
385+
}
379386
const body = html.querySelector('body')
380387
if (body && body instanceof MiniElement) {
381388
this.body = body
382389
}
383390
}
391+
if (!this.head) {
392+
const head = this.querySelector('head')
393+
if (head && head instanceof MiniElement) {
394+
this.head = head
395+
}
396+
}
384397
if (!this.body) {
385398
const body = this.querySelector('body')
386399
if (body && body instanceof MiniElement) {

0 commit comments

Comments
 (0)