-
Notifications
You must be signed in to change notification settings - Fork 25
/
portalling.kt
117 lines (99 loc) · 4.02 KB
/
portalling.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
package dev.fritz2.headless.foundation
import dev.fritz2.core.*
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import org.w3c.dom.*
private val portalRootId by lazy { "portal-root".also { addGlobalStyle("#$it { display: contents; }") } }
private object PortalStack : RootStore<List<PortalContainer<out HTMLElement>>>(emptyList(), job = Job()) {
val add = handle<PortalContainer<out HTMLElement>> { stack, it -> stack + it }
val remove = handle<String> { stack, id -> stack.filterNot { it.portalId == id } }
}
private data class PortalContainer<C : HTMLElement>(
val classes: String?,
val id: String?,
val scope: (ScopeContext.() -> Unit),
val tag: TagFactory<Tag<C>>,
val reference: MountPoint?,
val content: Tag<C>.(close: suspend (Unit) -> Unit) -> Unit
) {
val portalId = Id.next() // used for renderEach only
val remove = PortalStack.handle { list -> list.filterNot { it.portalId == portalId } }
fun render(ctx: RenderContext) =
tag(ctx, classes, id, scope + { ctx.scope[MOUNT_POINT_KEY]?.let { set(MOUNT_POINT_KEY, it) } }) {
content.invoke(this) { remove.invoke() }
reference?.beforeUnmount(this, null) { _, _ -> remove.invoke() }
}
}
/**
* A [portalRoot] is needed to use floating components like [modal], [toast] and [popupPanel].
*
* Should be the last element in `document.body` to ensure it will not be clipped by other elements.
*
* @see portal
*/
fun RenderContext.portalRoot(scopeContext: (ScopeContext.() -> Unit) = {}): RenderContext {
addComponentStructureInfo(portalRootId, this.scope, this)
register(PortalRenderContext.withScope(scopeContext + scope)) {}
return PortalRenderContext
}
internal object PortalRenderContext : HtmlTag<HTMLDivElement>("div", portalRootId, null, Job(), Scope()) {
var scopeContext: ScopeContext.() -> Unit = {}
fun withScope(scopeContext: ScopeContext.() -> Unit): PortalRenderContext = apply {
this.scopeContext = scopeContext + scope
}
init {
attr(Aria.live, "polite")
PortalStack.data.distinctUntilChangedBy { it.map { it.portalId } }
.renderEach(PortalContainer<*>::portalId, into = this) {
it.render(this)
}
MainScope().launch {
delay(500)
if (domNode.parentNode == null) {
console.error("you have to create a portalRoot to use portalled components (e.g. popup, modal and toast)")
}
}
}
}
/**
* With Portalling a rendered overlay will be rendered outside of the clipping ancestors to avoid clipping.
* Therefore, a [portalRoot] is needed as last element in the document.body.
*
* See https://floating-ui.com/docs/misc#clipping for more information.
*
* A Portal might have a reference element. When the reference element is removed from the DOM, the portal will either.
* The reference element is always the receiver Type [Tag<HTMLElement>] of the [portal] extension function.
*/
fun <C : HTMLElement> RenderContext.portal(
classes: String? = null,
id: String? = null,
scope: (ScopeContext.() -> Unit) = {},
tag: TagFactory<Tag<C>>,
content: Tag<C>.(close: suspend (Unit) -> Unit) -> Unit = {}
) {
val portalId = id ?: Id.next()
// toasts and modals are rendered directly into the PortalRenderContext, they do not need a reference
val reference = if (this != PortalRenderContext) this else null
PortalStack.add(
PortalContainer(
classes = classes,
id = portalId,
scope = this.scope + scope,
tag = tag,
reference = reference?.mountPoint(),
content = content
)
)
}
/**
* @see portal
*/
fun RenderContext.portal(
classes: String? = null,
id: String? = null,
scope: (ScopeContext.() -> Unit) = {},
content: Tag<HTMLDivElement>.(close: suspend (Unit) -> Unit) -> Unit,
) = portal(classes, id, scope, RenderContext::div, content)