Skip to content

Commit

Permalink
Merge pull request #489 from tuanchauict/ui/improve-dropdown
Browse files Browse the repository at this point in the history
Rewrite Dropdown menu in compose
  • Loading branch information
tuanchauict authored May 25, 2023
2 parents 5a5d562 + 93de0b1 commit 9786519
Show file tree
Hide file tree
Showing 8 changed files with 273 additions and 206 deletions.
109 changes: 0 additions & 109 deletions libs/ui-modal/src/main/kotlin/mono/html/modal/DropDownMenu.kt

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2023, tuanchauict
*/

@file:Suppress("FunctionName")

package mono.html.modal.compose

import androidx.compose.runtime.Composable
import kotlinx.browser.document
import mono.html.px
import mono.html.style
import org.jetbrains.compose.web.dom.Li
import org.jetbrains.compose.web.dom.Text
import org.jetbrains.compose.web.dom.Ul
import org.w3c.dom.Element

/**
* Show drop down menu at the bottom of the anchor, horizontal center aligned with the anchor.
*/
fun DropDownMenu(
anchor: Element,
items: List<DropDownItem>,
onClick: (DropDownItem) -> Unit
) {
NoBackgroundModal(
attrs = {
classes("drop-down-menu")

ref {
it.adjustPosition(anchor)
onDispose { }
}
}
) {
Ul {
for (item in items) {
if (!item.isVisible()) {
continue
}
when (item) {
is DropDownItem.Divider -> Divider()
is DropDownItem.Text -> Item(item) {
onClick(it)
dismiss()
}
}
}
}
}
}

@Composable
private fun Divider() {
Li(attrs = { classes("drop-down-divider") })
}

@Composable
private fun Item(item: DropDownItem.Text, onClick: (DropDownItem) -> Unit) {
Li(
attrs = {
classes("drop-down-item")
onClick { onClick(item) }
}
) {
Text(item.title)
}
}

private fun Element.adjustPosition(anchor: Element) {
val maxWidth = document.body?.clientWidth ?: return
val menuWidthPx = clientWidth
val anchorRect = anchor.getBoundingClientRect()

val leftPx =
(anchorRect.left + anchorRect.width / 2 - menuWidthPx / 2).toInt()
.coerceIn(4, maxWidth - menuWidthPx - 4)
style(
"left" to leftPx.px,
"top" to anchorRect.bottom.px
)
}

sealed class DropDownItem(internal val isVisible: () -> Boolean) {
class Divider(isVisible: () -> Boolean = { true }) : DropDownItem(isVisible)
class Text(val title: String, val key: Any, isVisible: () -> Boolean = { true }) :
DropDownItem(isVisible)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2023, tuanchauict
*/

package mono.html.modal.compose

import org.jetbrains.compose.web.dom.ElementScope
import org.w3c.dom.HTMLDivElement

/**
* A [ElementScope] for modal, which has extra APIs for communicating with content of the modal.
*/
class ModalElementScope(
private val elementScope: ElementScope<HTMLDivElement>,
val dismiss: () -> Unit
) : ElementScope<HTMLDivElement> by elementScope
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Copyright (c) 2023, tuanchauict
*/

@file:Suppress("FunctionName")

package mono.html.modal.compose

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlinx.browser.document
import mono.common.Cancelable
import mono.common.setTimeout
import mono.html.Div
import org.jetbrains.compose.web.css.Position
import org.jetbrains.compose.web.css.height
import org.jetbrains.compose.web.css.left
import org.jetbrains.compose.web.css.position
import org.jetbrains.compose.web.css.px
import org.jetbrains.compose.web.css.width
import org.jetbrains.compose.web.dom.AttrBuilderContext
import org.jetbrains.compose.web.dom.CheckboxInput
import org.jetbrains.compose.web.dom.Div
import org.jetbrains.compose.web.renderComposable
import org.w3c.dom.HTMLDivElement

/**
* Show a modal without background
*/
internal fun NoBackgroundModal(
attrs: AttrBuilderContext<HTMLDivElement>,
onDismiss: () -> Unit = {},
content: @Composable ModalElementScope.() -> Unit
) {
val body = document.body ?: return
val container = body.Div()
val composition = renderComposable(container) {}
composition.setContent {
var isDismissed by remember { mutableStateOf(false) }
ModalContainer(attrs, content) {
if (!isDismissed) {
composition.dispose()
container.remove()
onDismiss()
isDismissed = true
}
}
}
}

@Composable
private fun ModalContainer(
attrs: AttrBuilderContext<HTMLDivElement>,
content: @Composable ModalElementScope.() -> Unit,
dismiss: () -> Unit
) {
var cancelable: Cancelable? by remember { mutableStateOf(null) }
Div(
attrs = {
classes("no-background-modal")
tabIndex(-1)

onFocusIn { cancelable?.cancel() }

onFocusOut {
if (document.hasFocus()) {
cancelable = setTimeout(20) {
dismiss()
}
}
}

onKeyDown {
when (it.key) {
"Escape" -> dismiss()
// TODO: Use ArrowDown and ArrowUp for changing the active project
// TODO: Use Enter for opening the project by keyboard
}
}

attrs.invoke(this)
}
) {
// Hidden input for making the modal focused by default.
// Making the modal focused by default can make the input of the modal not being focused
// by default.
CheckboxInput {
style {
position(Position.Fixed)
left((-1000).px)
width(0.px)
height(0.px)
}
ref {
it.focus()
onDispose { }
}
}
val scope = ModalElementScope(this, dismiss)
content.invoke(scope)
}
}
Loading

0 comments on commit 9786519

Please sign in to comment.