Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes some bug with modals and reactive renderings #907

Merged
merged 1 commit into from
Oct 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions headless-demo/src/jsMain/kotlin/dev/fritz2/headlessdemo/app.kt
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,14 @@ fun main() {
val router = routerOf("")

render {
router.data.render { route ->
div("p-4", scope = { set(SHOW_COMPONENT_STRUCTURE, true) }) {
(pages[route]?.content ?: RenderContext::overview)()
main(scope = { set(SHOW_COMPONENT_STRUCTURE, true) }) {
router.data.render { route ->
div("p-4") {
(pages[route]?.content ?: RenderContext::overview)()
}
}
}

portalRoot()
portalRoot()
}
}
}
28 changes: 26 additions & 2 deletions headless/src/jsMain/kotlin/dev/fritz2/headless/components/modal.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import dev.fritz2.headless.foundation.*
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.take
import org.w3c.dom.*

Expand All @@ -30,12 +31,35 @@ class Modal(id: String?) : OpenClose() {
fun init() {
opened.filter { it }.handledBy {
PortalRenderContext.run {
portal(id = componentId, tag = RenderContext::dialog, scope = scopeContext) { close ->
portal(id = componentId, tag = RenderContext::dialog, scope = scopeContext) { remove ->
inlineStyle("display: contents")
panel?.invoke(this)!!.apply {
trapFocusInMountpoint(restoreFocus, setInitialFocus)
}
opened.filter { !it }.map { }.take(1) handledBy close
opened.onCompletion {
/*
* This needs to be explained:
* As a `modal` is not dependent on any `Tag<*>`, we cannot provide some
* `PortalContainer.reference`-object. The latter is needed to couple the portal-portion of
* a component to its counterpart inside the normal `RenderContext` (or fritz2 controlled
* subtree if that is more understandable). From that reference we can get its nearest
* `MountPoint` and rely on the latter to register some `DomLifecycleHandler` by the
* `beforeUnmount`-lifecycle hook. In the case of a portal, we can call its `remove`-handler,
* which itself will change the global `PortalStack`, which will then reactively execute
* `renderEach` on all portals. So the portal-portion will get removed if its reference is
* reactively removed. This is always the case if the reference lives inside some reactive
* scope, which are created by any `render*`-call. (This is true for `Router`-based content
* too of course!)
*
* As we do not have such a reference here, we can only refer to the data-binding-flow, which
* will normally reside inside some reactive scope. So if the `Job` of this `Flow` is canceled
* due to some normal fritz2 reactive action, we know that the modal must also be removed from
* the DOM.
*/
remove(Unit)
}.filter { !it }
.map { }
.take(1) handledBy remove
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ private val portalRootId by lazy { "portal-root".also { addGlobalStyle("#$it { d

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>(
Expand All @@ -22,21 +21,29 @@ private data class PortalContainer<C : HTMLElement>(
val scope: (ScopeContext.() -> Unit),
val tag: TagFactory<Tag<C>>,
val reference: MountPoint?,
val content: Tag<C>.(close: suspend (Unit) -> Unit) -> Unit
val content: Tag<C>.(remove: suspend (Unit) -> Unit) -> Unit
) {
val portalId = Id.next() // used for renderEach only
/**
* used as ID-provider for the rendering of `PortalStack.data`
*/
val portalId = Id.next()

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) } }) {
scope[SHOW_COMPONENT_STRUCTURE]?.let {
if (it) attr("data-portal-id", portalId)
}
content.invoke(this) { remove.invoke() }
reference?.beforeUnmount(this, null) { _, _ -> remove.invoke() }
}
}

/**
* A [portalRoot] is needed to use floating components like [modal], [toast] and [popupPanel].
* A [portalRoot] is needed to use floating components like [dev.fritz2.headless.components.modal],
* [dev.fritz2.headless.components.toast] and [dev.fritz2.headless.components.popOver].
* Basically all components based upon [PopUpPanel].
*
* Should be the last element in `document.body` to ensure it will not be clipped by other elements.
*
Expand Down Expand Up @@ -91,7 +98,11 @@ fun <C : HTMLElement> RenderContext.portal(
) {
val portalId = id ?: Id.next()

// toasts and modals are rendered directly into the PortalRenderContext, they do not need a reference
/**
* toasts and modals are rendered directly into the PortalRenderContext, they do not need a reference.
* To be more precise: They do not have any valid reference, as for example the modal is rendered completely
* agnostic from the fritz2 controlled `RenderContext`.
*/
val reference = if (this != PortalRenderContext) this else null

PortalStack.add(
Expand Down
Loading