Skip to content

Commit 56a4d7f

Browse files
authored
chore: replace moveToBody with <Teleport /> built-in vue3 component (baserow#4872)
1 parent edb06c2 commit 56a4d7f

40 files changed

Lines changed: 5424 additions & 4380 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"type": "refactor",
3+
"message": "Replace custom moveToBody mixin with Vue 3 built-in Teleport component.",
4+
"issue_origin": "github",
5+
"issue_number": null,
6+
"domain": "core",
7+
"bullet_points": [],
8+
"created_at": "2026-04-08"
9+
}

premium/web-frontend/modules/baserow_premium/components/row_comments/RowComment.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,10 @@ export default {
215215
this.editing = true
216216
217217
this.onClickOutsideHandler = (evt) => {
218+
const contextRoot = this.$refs.commentContext.getTeleportedElement()
218219
if (
219220
!this.$el.contains(evt.target) &&
220-
!this.$refs.commentContext.$el.contains(evt.target) &&
221+
(!contextRoot || !contextRoot.contains(evt.target)) &&
221222
!evt.composedPath().includes(this.$el)
222223
) {
223224
this.stopEdit()

premium/web-frontend/modules/baserow_premium/components/views/grid/fields/GridViewFieldAI.vue

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
</template>
7070

7171
<script>
72-
import { isElement } from '@baserow/modules/core/utils/dom'
72+
import { isInsideTeleportedElement } from '@baserow/modules/core/utils/dom'
7373
import gridField from '@baserow/modules/database/mixins/gridField'
7474
import gridFieldAI from '@baserow_premium/mixins/gridFieldAI'
7575
@@ -142,10 +142,10 @@ export default {
142142
return true
143143
},
144144
canUnselectByClickingOutside(event) {
145-
if (this.isDeactivated && this.workspace) {
146-
return !isElement(this.$refs.clickModal.$el, event.target)
145+
if (!this.isDeactivated || !this.workspace) {
146+
return true
147147
}
148-
return true
148+
return !isInsideTeleportedElement(this.$refs.clickModal, event)
149149
},
150150
canSelectNext(event) {
151151
if (

premium/web-frontend/test/unit/premium/premiumViewTypes.spec.js

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import PaidFeaturesModal from '@baserow_premium/components/PaidFeaturesModal'
33
import { PremiumTestApp } from '@baserow_premium_test/helpers/premiumTestApp'
44
import flushPromises from 'flush-promises'
55
import CreateViewModal from '@baserow/modules/database/components/view/CreateViewModal'
6+
import CreateViewLink from '@baserow/modules/database/components/view/CreateViewLink'
67

78
async function openViewContextAndClickOnCreateKanbanView(
89
testApp,
@@ -18,17 +19,15 @@ async function openViewContextAndClickOnCreateKanbanView(
1819
})
1920

2021
await viewsContext.vm.show(viewsContext)
21-
// Show runs some extra code in a nextTick so flush them now.
2222
await flushPromises()
2323

2424
const paidFeaturesModal = viewsContext.findComponent(PaidFeaturesModal)
25-
expect(paidFeaturesModal.isVisible()).toBe(false)
25+
expect(paidFeaturesModal.vm.$refs.modal.open).toBe(false)
2626

27-
const kanbanLink = viewsContext
28-
.findAll('.select__footer-create-link')
29-
.filter((node) => node.text() === 'premium.viewType.kanban')
30-
.at(0)
31-
await kanbanLink.trigger('click')
27+
const kanbanCreateLink = viewsContext
28+
.findAllComponents(CreateViewLink)
29+
.find((c) => c.vm.viewType.getType() === 'kanban')
30+
kanbanCreateLink.vm.select()
3231
await flushPromises()
3332
return viewsContext
3433
}
@@ -48,9 +47,11 @@ describe('Premium View Type Component Tests', () => {
4847
expect(
4948
viewsContext
5049
.findAllComponents(CreateViewModal)
51-
.filter((m) => m.isVisible())
50+
.filter((m) => m.vm.$refs.modal.open)
5251
).toHaveLength(0)
53-
expect(viewsContext.findComponent(PaidFeaturesModal).isVisible()).toBe(true)
52+
expect(
53+
viewsContext.findComponent(PaidFeaturesModal).vm.$refs.modal.open
54+
).toBe(true)
5455
})
5556
test('User with global premium features can create Kanban view', async () => {
5657
testApp.giveCurrentUserGlobalPremiumFeatures()
@@ -60,10 +61,10 @@ describe('Premium View Type Component Tests', () => {
6061

6162
const visibleCreateViewModals = viewsContext
6263
.findAllComponents(CreateViewModal)
63-
.filter((m) => m.isVisible())
64+
.filter((m) => m.vm.$refs.modal.open)
6465
expect(visibleCreateViewModals).toHaveLength(1)
65-
expect(viewsContext.findComponent(PaidFeaturesModal).isVisible()).toBe(
66-
false
67-
)
66+
expect(
67+
viewsContext.findComponent(PaidFeaturesModal).vm.$refs.modal.open
68+
).toBe(false)
6869
})
6970
})

web-frontend/modules/core/components/Context.vue

Lines changed: 123 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,48 @@
11
<template>
2-
<div
3-
v-auto-overflow-scroll="open && overflowScroll"
4-
class="context"
5-
:class="{
6-
'visibility-hidden': !open || !updatedOnce,
7-
'context--overflow-scroll': overflowScroll,
8-
}"
9-
>
10-
<slot v-if="openedOnce"></slot>
11-
</div>
2+
<Teleport to="body">
3+
<div
4+
ref="contextEl"
5+
v-auto-overflow-scroll="open && overflowScroll"
6+
v-bind="$attrs"
7+
class="context"
8+
:class="{
9+
'visibility-hidden': !open || !updatedOnce,
10+
'context--overflow-scroll': overflowScroll,
11+
}"
12+
>
13+
<slot v-if="openedOnce"></slot>
14+
</div>
15+
</Teleport>
1216
</template>
1317

1418
<script>
1519
import {
16-
isElement,
20+
collectTeleportRootsForOutsideClick,
21+
getTeleportedElementFromRef,
1722
isDomElement,
23+
isElement,
1824
onClickOutside,
1925
} from '@baserow/modules/core/utils/dom'
2026
21-
import MoveToBody from '@baserow/modules/core/mixins/moveToBody'
22-
2327
export default {
2428
name: 'Context',
25-
mixins: [MoveToBody],
29+
provide() {
30+
return {
31+
registerChild: this.registerChild,
32+
unregisterChild: this.unregisterChild,
33+
}
34+
},
35+
inject: {
36+
parentRegisterChild: {
37+
from: 'registerChild',
38+
default: null,
39+
},
40+
parentUnregisterChild: {
41+
from: 'unregisterChild',
42+
default: null,
43+
},
44+
},
45+
inheritAttrs: false,
2646
props: {
2747
hideOnClickOutside: {
2848
type: Boolean,
@@ -69,7 +89,19 @@ export default {
6989
// If opened once, should stay in DOM to keep nested content
7090
openedOnce: false,
7191
maxHeightOffset: 10,
92+
children: [],
93+
}
94+
},
95+
mounted() {
96+
if (this.parentRegisterChild) {
97+
this.parentRegisterChild(this)
98+
}
99+
},
100+
beforeUnmount() {
101+
if (this.parentUnregisterChild) {
102+
this.parentUnregisterChild(this)
72103
}
104+
this.hide(false)
73105
},
74106
methods: {
75107
/**
@@ -131,6 +163,7 @@ export default {
131163
) {
132164
const isElementOrigin = isDomElement(target)
133165
const updatePosition = () => {
166+
const el = this.$refs.contextEl
134167
const css = isElementOrigin
135168
? this.calculatePositionElement(
136169
target,
@@ -160,11 +193,10 @@ export default {
160193
return
161194
}
162195
163-
// Set the calculated positions of the context.
164196
for (const key in css) {
165197
const cssValue =
166198
css[key] !== null ? Math.ceil(css[key]) + 'px' : 'auto'
167-
this.$el.style[key] = cssValue
199+
el.style[key] = cssValue
168200
}
169201
170202
// The max height can optionally be automatically to prevent the context from
@@ -178,7 +210,7 @@ export default {
178210
this.getWindowScrollHeight()
179211
}px)`
180212
: 'none'
181-
this.$el.style['max-height'] = maxHeight
213+
el.style['max-height'] = maxHeight
182214
}
183215
184216
this.updatedOnce = true
@@ -195,54 +227,62 @@ export default {
195227
await this.$nextTick()
196228
updatePosition()
197229
198-
this.$el.cancelOnClickOutside = onClickOutside(this.$el, (target) => {
199-
if (
200-
this.open &&
201-
// If the prop allows it to be closed by clicking outside.
202-
this.hideOnClickOutside &&
203-
// If the click was not on the opener because they can trigger the toggle
204-
// method.
205-
!isElement(this.opener, target) &&
206-
// If the click was not inside one of the context children of this context
207-
// menu.
208-
!this.moveToBody.children.some((child) => {
209-
return isElement(child.$el, target)
210-
})
211-
) {
212-
this.hide()
230+
// Cancel any previous handlers before setting up new ones
231+
this._cleanupEventHandlers()
232+
233+
const el = this.getTeleportedElement()
234+
235+
const ignoreElements = () => {
236+
const childRoots = []
237+
for (const child of this.children) {
238+
childRoots.push(...collectTeleportRootsForOutsideClick(child))
239+
}
240+
return [
241+
this.opener,
242+
...new Set(childRoots.filter((el) => el instanceof HTMLElement)),
243+
].filter(Boolean)
244+
}
245+
246+
this._cancelOnClickOutside = onClickOutside(
247+
el,
248+
() => {
249+
if (this.open && this.hideOnClickOutside) {
250+
this.hide()
251+
}
252+
},
253+
{
254+
ignoreElements,
213255
}
214-
})
256+
)
215257
216-
this.$el.updatePositionViaScrollEvent = (event) => {
258+
this._updatePositionViaScrollEvent = (event) => {
217259
if (this.hideOnScroll) {
218260
this.hide()
219261
} else if (
220-
// The context menu itself can have a scrollbar, and resizing everytime you
221-
// scroll internally doesn't make sense because it can't influence the position.
222-
!isElement(this.$el, event.target) &&
223-
// If the scroll was not inside one of the context children of this context
224-
// menu.
225-
!this.moveToBody.children.some((child) => {
226-
return isElement(child.$el, target)
227-
})
262+
!isElement(this.getTeleportedElement(), event.target) &&
263+
!this.children.some((child) =>
264+
collectTeleportRootsForOutsideClick(child).some((root) =>
265+
isElement(root, event.target)
266+
)
267+
)
228268
) {
229269
updatePosition()
230270
}
231271
}
232272
window.addEventListener(
233273
'scroll',
234-
this.$el.updatePositionViaScrollEvent,
274+
this._updatePositionViaScrollEvent,
235275
true
236276
)
237277
238-
this.$el.updatePositionViaResizeEvent = () => {
278+
this._updatePositionViaResizeEvent = () => {
239279
if (this.hideOnResize) {
240280
this.hide()
241281
} else {
242282
updatePosition()
243283
}
244284
}
245-
window.addEventListener('resize', this.$el.updatePositionViaResizeEvent)
285+
window.addEventListener('resize', this._updatePositionViaResizeEvent)
246286
247287
this.$emit('shown')
248288
},
@@ -309,22 +349,25 @@ export default {
309349
this.$emit('hidden')
310350
}
311351
312-
// If the context menu was never opened, it doesn't have the
313-
// `cancelOnClickOutside`, so we can't call it.
314-
if (
315-
Object.prototype.hasOwnProperty.call(this.$el, 'cancelOnClickOutside')
316-
) {
317-
this.$el.cancelOnClickOutside()
352+
this._cleanupEventHandlers()
353+
},
354+
_cleanupEventHandlers() {
355+
if (this._cancelOnClickOutside) {
356+
this._cancelOnClickOutside()
357+
this._cancelOnClickOutside = null
358+
}
359+
if (this._updatePositionViaScrollEvent) {
360+
window.removeEventListener(
361+
'scroll',
362+
this._updatePositionViaScrollEvent,
363+
true
364+
)
365+
this._updatePositionViaScrollEvent = null
366+
}
367+
if (this._updatePositionViaResizeEvent) {
368+
window.removeEventListener('resize', this._updatePositionViaResizeEvent)
369+
this._updatePositionViaResizeEvent = null
318370
}
319-
window.removeEventListener(
320-
'scroll',
321-
this.$el.updatePositionViaScrollEvent,
322-
true
323-
)
324-
window.removeEventListener(
325-
'resize',
326-
this.$el.updatePositionViaResizeEvent
327-
)
328371
},
329372
/**
330373
* Calculates the absolute position of the context based on the original clicked
@@ -483,11 +526,9 @@ export default {
483526
verticalOffset,
484527
horizontalOffset
485528
) {
486-
const contextRect = this.$el.getBoundingClientRect()
487-
// We need to use the scrollHeight in the calculations because we need to work
488-
// with the full height of the element without scrollbar to calculate the optimal
489-
// position.
490-
const scrollHeight = this.$el.scrollHeight
529+
const el = this.$refs.contextEl
530+
const contextRect = el.getBoundingClientRect()
531+
const scrollHeight = el.scrollHeight
491532
const canTop =
492533
targetRect.top -
493534
scrollHeight -
@@ -543,6 +584,23 @@ export default {
543584
isOpen() {
544585
return this.open
545586
},
587+
/**
588+
* Root element rendered inside `<Teleport>` (not the anchor `this.$el`).
589+
*
590+
* @returns {HTMLElement|null}
591+
*/
592+
getTeleportedElement() {
593+
return getTeleportedElementFromRef(this.$refs, 'contextEl')
594+
},
595+
registerChild(child) {
596+
this.children.push(child)
597+
},
598+
unregisterChild(child) {
599+
const index = this.children.indexOf(child)
600+
if (index !== -1) {
601+
this.children.splice(index, 1)
602+
}
603+
},
546604
},
547605
}
548606
</script>

0 commit comments

Comments
 (0)