Skip to content

Commit

Permalink
added portalling to PopupPanel, Toast and Modal
Browse files Browse the repository at this point in the history
  • Loading branch information
metin-kale committed Aug 11, 2023
1 parent fbbc410 commit f446629
Show file tree
Hide file tree
Showing 6 changed files with 132 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package dev.fritz2.headlessdemo
import dev.fritz2.core.*
import dev.fritz2.headless.foundation.SHOW_COMPONENT_STRUCTURE
import dev.fritz2.headlessdemo.components.*
import dev.fritz2.headless.foundation.portalRoot
import dev.fritz2.headlessdemo.foundation.testTrapFocus
import dev.fritz2.routing.routerOf

Expand Down Expand Up @@ -135,5 +136,7 @@ fun main() {
(pages[route]?.content ?: RenderContext::overview)()
}
}

portalRoot ()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,9 @@ class Modal(val renderContext: RenderContext) : RenderContext by renderContext,
*/
fun RenderContext.modal(
initialize: Modal.() -> Unit
) = Modal(this).run {
initialize(this)
render()
}
) = portalContainer(zIndex = PORTALLING_MODAL_ZINDEX) {
Modal(this).run {
initialize(this)
render()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ fun <E : HTMLElement> RenderContext.toastContainer(
tag: TagFactory<Tag<E>>
): Tag<E> {
addComponentStructureInfo("toast-container ($name)", this.scope, this)
return tag(this, classes, id, scope) {
return tag.portalled(PORTALLING_TOAST_ZINDEX).invoke(this, classes, id, scope) {
attrIfNotSet(Aria.live, "polite")
ToastStore.filteredByContainer(name).renderEach(into = this) { fragment ->
fragment.content(this)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ abstract class PopUpPanel<C : HTMLElement>(
private val fullWidth: Boolean = true,
private val reference: Tag<HTMLElement>?,
private val ariaHasPopup: String,
private val popupDiv: HtmlTag<HTMLDivElement> = renderContext.div(POPUP_HIDDEN_CLASSES) {}, //never add other classes to popupDiv, they will be overridden
private val popupDiv: Tag<HTMLDivElement> = //never add other classes to popupDiv, they will be overridden
renderContext.portalContainer(POPUP_HIDDEN_CLASSES, tag = RenderContext::div, zIndex = PORTALLING_POPUP_ZINDEX) {},
tag: Tag<C> = tagFactory(popupDiv, classes, id, scope) {},
private val config: ComputePositionConfig = obj {}
) : Tag<C> by tag, ComputePositionConfig by config {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package dev.fritz2.headless.foundation

import dev.fritz2.core.*
import kotlinx.browser.document
import kotlinx.coroutines.*
import org.w3c.dom.Element
import org.w3c.dom.HTMLDivElement
import org.w3c.dom.Node


const val PORTALLING_MODAL_ZINDEX = 10
const val PORTALLING_POPUP_ZINDEX = 30
const val PORTALLING_TOAST_ZINDEX = 50


private val portalRootId by lazy { "portal-root".also { addGlobalStyle("#$it { display: contents; }") } }
private val portalCompanionClass by lazy { "portal-companion".also { addGlobalStyle(".$it { display: contents; display: none; }") } }
private val portalContainerClass by lazy {
"portal-container".also {
addGlobalStyles(
listOf(
".$it { display: contents; }",
".$it > div { z-index: inherit; }"
)
)
}
}

/**
* Ein PortalRoot wird benötigt, um alle Overlays darin zu rendern. Sollte als letztes Element `document.body` stehen
*
* @see portalContainer
*/
fun RenderContext.portalRoot(): RenderContext {
addComponentStructureInfo(portalRootId, this.scope, this)
register(PortalRenderContext) {}
return PortalRenderContext
}

private object PortalRenderContext : RenderContext, WithDomNode<Element> {
override val job: Job = Job()
override val scope: Scope = Scope()
override val domNode: Element by lazy {
document.createElement("div").apply {
id = portalRootId
setAttribute(Aria.live, "polite")
}
}

override fun <N : Node, W : WithDomNode<N>> register(element: W, content: (W) -> Unit): W {
content(element)
domNode.appendChild(element.domNode)
return element
}

init {
(MainScope() + job).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-Container always comes with a Companion-Element, which is rendered directly into the [RenderContext].
* The Companion-Element is used to cleanup the decoupled PortalContainer when the companion-Element gets removed.
*/
fun <C : Element> RenderContext.portalContainer(
classes: String? = null,
id: String? = null,
scope: (ScopeContext.() -> Unit) = {},
tag: TagFactory<Tag<C>>,
zIndex: Int,
content: Tag<C>.() -> Unit = {}
): Tag<C> {
val companion = div(id = "$id-companion", baseClass = portalCompanionClass) {}
return export {
PortalRenderContext.run {
div(portalContainerClass, "$id-portal", scope) {
domNode.style.zIndex = zIndex.toString()
export(tag(this, classes, id, scope) { content() })
companion.beforeUnmount { _, _ ->
PortalRenderContext.domNode.removeChild(domNode)
}
}
}
}
}

/**
* @see portalContainer
*/
fun RenderContext.portalContainer(
classes: String? = null,
id: String? = null,
scope: (ScopeContext.() -> Unit) = {},
zIndex: Int,
content: Tag<HTMLDivElement>.() -> Unit
): Tag<HTMLDivElement> = portalContainer(classes, id, scope, RenderContext::div, zIndex, content)


/**
* A convenience function to wrap a [TagFactory] with a [portalContainer]
* @see portalContainer
*/
fun <E : Element> TagFactory<Tag<E>>.portalled(zIndex: Int): TagFactory<Tag<E>> = { ctx, classes, id, scope, content ->
ctx.portalContainer(classes, id, scope, this, zIndex, content)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import dev.fritz2.core.Id
import dev.fritz2.core.RenderContext
import dev.fritz2.core.asElementList
import dev.fritz2.core.render
import dev.fritz2.headless.foundation.portalRoot
import dev.fritz2.headless.getElementById
import dev.fritz2.headless.model.TestModel
import dev.fritz2.headless.model.listBoxEntries
Expand Down Expand Up @@ -61,6 +62,9 @@ class ListBoxTest {
attr("data-message", value.validationMessages.map { it.firstOrNull()?.message ?: "" })
}
}

portalRoot()

}

delay(500)
Expand Down

0 comments on commit f446629

Please sign in to comment.