From fd5ff5e30ca7ff2d1f8914ce58063638ae5bc267 Mon Sep 17 00:00:00 2001 From: Alexander Nozik Date: Sat, 20 Apr 2024 12:13:43 +0300 Subject: [PATCH] Replace external bootsrap dependency with a copy --- build.gradle.kts | 6 +- .../visionforge/gdml/demo/FileDrop.kt | 4 +- .../src/jsMain/kotlin/gravityDemo.kt | 4 +- demo/muon-monitor/build.gradle.kts | 43 +- .../mipt/npm/muon/monitor/MMAppComponent.kt | 16 +- .../ru/mipt/npm/muon/monitor/MMServer.kt | 3 +- demo/playground/build.gradle.kts | 11 +- gradle.properties | 7 +- settings.gradle.kts | 1 - visionforge-compose-html/README.md | 21 + visionforge-compose-html/build.gradle.kts | 9 +- .../src/jsMain/kotlin/bootstrap/Alerts.kt | 57 ++ .../jsMain/kotlin/bootstrap/Autocomplete.kt | 204 +++++ .../src/jsMain/kotlin/bootstrap/Badge.kt | 36 + .../src/jsMain/kotlin/bootstrap/Box.kt | 26 + .../src/jsMain/kotlin/bootstrap/Brand.kt | 27 + .../src/jsMain/kotlin/bootstrap/Breakpoint.kt | 29 + .../src/jsMain/kotlin/bootstrap/Button.kt | 95 +++ .../jsMain/kotlin/bootstrap/ButtonGroup.kt | 24 + .../src/jsMain/kotlin/bootstrap/Card.kt | 49 ++ .../src/jsMain/kotlin/bootstrap/Checkbox.kt | 63 ++ .../src/jsMain/kotlin/bootstrap/Classes.kt | 99 +++ .../jsMain/kotlin/bootstrap/CloseButton.kt | 26 + .../src/jsMain/kotlin/bootstrap/Collapse.kt | 36 + .../src/jsMain/kotlin/bootstrap/Color.kt | 23 + .../src/jsMain/kotlin/bootstrap/Column.kt | 33 + .../src/jsMain/kotlin/bootstrap/Container.kt | 40 + .../src/jsMain/kotlin/bootstrap/DropDown.kt | 194 +++++ .../jsMain/kotlin/bootstrap/FormFloating.kt | 30 + .../src/jsMain/kotlin/bootstrap/FormLabel.kt | 32 + .../src/jsMain/kotlin/bootstrap/GridBox.kt | 773 ++++++++++++++++++ .../src/jsMain/kotlin/bootstrap/Icon.kt | 30 + .../src/jsMain/kotlin/bootstrap/Input.kt | 45 + .../src/jsMain/kotlin/bootstrap/InputGroup.kt | 561 +++++++++++++ .../src/jsMain/kotlin/bootstrap/ListGroup.kt | 182 +++++ .../src/jsMain/kotlin/bootstrap/Modal.kt | 84 ++ .../src/jsMain/kotlin/bootstrap/Navbar.kt | 221 +++++ .../src/jsMain/kotlin/bootstrap/OffCanvas.kt | 137 ++++ .../src/jsMain/kotlin/bootstrap/Pagination.kt | 67 ++ .../src/jsMain/kotlin/bootstrap/README.md | 1 + .../src/jsMain/kotlin/bootstrap/Radio.kt | 69 ++ .../src/jsMain/kotlin/bootstrap/Range.kt | 43 + .../src/jsMain/kotlin/bootstrap/Row.kt | 94 +++ .../src/jsMain/kotlin/bootstrap/Select.kt | 105 +++ .../src/jsMain/kotlin/bootstrap/Styling.kt | 557 +++++++++++++ .../src/jsMain/kotlin/bootstrap/Table.kt | 449 ++++++++++ .../src/jsMain/kotlin/bootstrap/Toasts.kt | 184 +++++ .../src/jsMain/kotlin/bootstrap/Toggler.kt | 34 + .../src/jsMain/kotlin/bootstrap/ZIndex.kt | 18 + .../src/jsMain/kotlin/bootstrap/bootstrap.kt | 24 + .../src/jsMain/kotlin/bootstrap/popper.kt | 6 + .../src/jsMain/kotlin/bootstrap/scss.kt | 5 + .../visionforge/html/PropertyEditor.kt | 19 +- .../space/kscience/visionforge/html/Tabs.kt | 6 +- .../kscience/visionforge/html/valueChooser.kt | 5 +- .../space/kscience/visionforge/VisionGroup.kt | 2 +- .../visionforge/markup/MarkupPlugin.kt | 8 +- .../solid/three/compose/ThreeControls.kt | 12 +- .../solid/three/compose/ThreeView.kt | 16 +- 59 files changed, 4910 insertions(+), 95 deletions(-) create mode 100644 visionforge-compose-html/README.md create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Alerts.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Autocomplete.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Badge.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Box.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Brand.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Breakpoint.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Button.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/ButtonGroup.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Card.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Checkbox.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Classes.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/CloseButton.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Collapse.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Color.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Column.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Container.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/DropDown.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/FormFloating.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/FormLabel.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/GridBox.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Icon.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Input.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/InputGroup.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/ListGroup.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Modal.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Navbar.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/OffCanvas.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Pagination.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/README.md create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Radio.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Range.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Row.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Select.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Styling.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Table.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Toasts.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/Toggler.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/ZIndex.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/bootstrap.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/popper.kt create mode 100644 visionforge-compose-html/src/jsMain/kotlin/bootstrap/scss.kt diff --git a/build.gradle.kts b/build.gradle.kts index eb7e4fe4..24159d9f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ val dataforgeVersion by extra("0.8.0") allprojects { group = "space.kscience" - version = "0.4.1" + version = "0.4.2-dev-2" } subprojects { @@ -25,8 +25,8 @@ subprojects { } tasks.withType { - kotlinOptions { - freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers" + compilerOptions { + freeCompilerArgs.add("-Xcontext-receivers") } } diff --git a/demo/gdml/src/jsMain/kotlin/space/kscience/visionforge/gdml/demo/FileDrop.kt b/demo/gdml/src/jsMain/kotlin/space/kscience/visionforge/gdml/demo/FileDrop.kt index 3fdedb64..51fa1072 100644 --- a/demo/gdml/src/jsMain/kotlin/space/kscience/visionforge/gdml/demo/FileDrop.kt +++ b/demo/gdml/src/jsMain/kotlin/space/kscience/visionforge/gdml/demo/FileDrop.kt @@ -3,8 +3,8 @@ package space.kscience.visionforge.gdml.demo import androidx.compose.runtime.* -import app.softwork.bootstrapcompose.Container -import app.softwork.bootstrapcompose.Icon +import bootstrap.Container +import bootstrap.Icon import org.jetbrains.compose.web.ExperimentalComposeWebApi import org.jetbrains.compose.web.attributes.InputType import org.jetbrains.compose.web.attributes.name diff --git a/demo/js-playground/src/jsMain/kotlin/gravityDemo.kt b/demo/js-playground/src/jsMain/kotlin/gravityDemo.kt index 9a5089fa..a4a6706d 100644 --- a/demo/js-playground/src/jsMain/kotlin/gravityDemo.kt +++ b/demo/js-playground/src/jsMain/kotlin/gravityDemo.kt @@ -1,8 +1,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import app.softwork.bootstrapcompose.Column -import app.softwork.bootstrapcompose.Row +import bootstrap.Column +import bootstrap.Row import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import org.jetbrains.compose.web.css.* diff --git a/demo/muon-monitor/build.gradle.kts b/demo/muon-monitor/build.gradle.kts index 3893e40e..82263640 100644 --- a/demo/muon-monitor/build.gradle.kts +++ b/demo/muon-monitor/build.gradle.kts @@ -1,65 +1,54 @@ plugins { id("space.kscience.gradle.mpp") alias(spclibs.plugins.compose) +// alias(spclibs.plugins.ktor) application } group = "ru.mipt.npm" -val ktorVersion: String = spclibs.versions.ktor.get() kscience { - useCoroutines() - useSerialization() - useKtor() fullStack( "muon-monitor.js", - jvmConfig = { withJava() }, -// jsConfig = { useCommonJs() }, + jvmConfig = {withJava()}, browserConfig = { - webpackTask{ - cssSupport{ + commonWebpackConfig { + cssSupport { enabled = true } - scssSupport{ + scssSupport { enabled = true } } } ) + useCoroutines() + useSerialization() + useKtor() + commonMain { implementation(projects.visionforgeSolid) implementation(projects.visionforgeComposeHtml) } jvmMain { implementation("org.apache.commons:commons-math3:3.6.1") - implementation("io.ktor:ktor-server-cio:${ktorVersion}") - implementation("io.ktor:ktor-server-content-negotiation:${ktorVersion}") - implementation("io.ktor:ktor-serialization-kotlinx-json:${ktorVersion}") - implementation("ch.qos.logback:logback-classic:1.2.11") + implementation("io.ktor:ktor-server-cio") + implementation("io.ktor:ktor-server-content-negotiation") + implementation("io.ktor:ktor-serialization-kotlinx-json") + implementation(spclibs.logback.classic) } jsMain { implementation(projects.visionforgeThreejs) //implementation(devNpm("webpack-bundle-analyzer", "4.4.0")) } } -kotlin{ +kotlin { explicitApi = null } application { - mainClass.set("ru.mipt.npm.muon.monitor.server.MMServerKt") -} - -//distributions { -// main { -// contents { -// from("$buildDir/libs") { -// rename("${rootProject.name}-jvm", rootProject.name) -// into("lib") -// } -// } -// } -//} \ No newline at end of file + mainClass.set("ru.mipt.npm.muon.monitor.MMServerKt") +} \ No newline at end of file diff --git a/demo/muon-monitor/src/jsMain/kotlin/ru/mipt/npm/muon/monitor/MMAppComponent.kt b/demo/muon-monitor/src/jsMain/kotlin/ru/mipt/npm/muon/monitor/MMAppComponent.kt index 63924c42..aa8894cf 100644 --- a/demo/muon-monitor/src/jsMain/kotlin/ru/mipt/npm/muon/monitor/MMAppComponent.kt +++ b/demo/muon-monitor/src/jsMain/kotlin/ru/mipt/npm/muon/monitor/MMAppComponent.kt @@ -3,16 +3,15 @@ package ru.mipt.npm.muon.monitor import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember -import app.softwork.bootstrapcompose.Button -import app.softwork.bootstrapcompose.ButtonGroup -import app.softwork.bootstrapcompose.Color.Secondary -import app.softwork.bootstrapcompose.Container -import app.softwork.bootstrapcompose.Layout.Width +import bootstrap.Button +import bootstrap.ButtonGroup +import bootstrap.Layout.Width.Full import kotlinx.browser.window import kotlinx.coroutines.await import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import org.jetbrains.compose.web.css.* +import org.jetbrains.compose.web.dom.Div import org.jetbrains.compose.web.dom.P import org.jetbrains.compose.web.dom.Span import org.jetbrains.compose.web.dom.Text @@ -52,8 +51,9 @@ fun MMApp(context: Context, model: Model, selected: Name? = null) { val events = remember { mutableStateListOf() } - Container(fluid = true, + Div( attrs = { + classes("container-fluid") style { height(100.vh - 12.pt) } @@ -66,7 +66,7 @@ fun MMApp(context: Context, model: Model, selected: Name? = null) { options = mmOptions, sidebarTabs = { Tab("Events") { - ButtonGroup({ Layout.width = Width.Full }) { + ButtonGroup({ Layout.width = Full }) { Button("Next") { context.launch { val event = window.fetch( @@ -85,7 +85,7 @@ fun MMApp(context: Context, model: Model, selected: Name? = null) { model.displayEvent(event) } } - Button("Clear", color = Secondary) { + Button("Clear", color = bootstrap.Color.Secondary) { events.clear() model.reset() } diff --git a/demo/muon-monitor/src/jvmMain/kotlin/ru/mipt/npm/muon/monitor/MMServer.kt b/demo/muon-monitor/src/jvmMain/kotlin/ru/mipt/npm/muon/monitor/MMServer.kt index d3d50c5b..839168f6 100644 --- a/demo/muon-monitor/src/jvmMain/kotlin/ru/mipt/npm/muon/monitor/MMServer.kt +++ b/demo/muon-monitor/src/jvmMain/kotlin/ru/mipt/npm/muon/monitor/MMServer.kt @@ -1,4 +1,4 @@ -package ru.mipt.npm.muon.monitor.server +package ru.mipt.npm.muon.monitor import io.ktor.http.ContentType @@ -17,7 +17,6 @@ import io.ktor.server.response.respondText import io.ktor.server.routing.Routing import io.ktor.server.routing.get import org.apache.commons.math3.random.JDKRandomGenerator -import ru.mipt.npm.muon.monitor.Model import ru.mipt.npm.muon.monitor.sim.Cos2TrackGenerator import ru.mipt.npm.muon.monitor.sim.simulateOne import space.kscience.dataforge.context.Context diff --git a/demo/playground/build.gradle.kts b/demo/playground/build.gradle.kts index 71004852..7d5dd0b0 100644 --- a/demo/playground/build.gradle.kts +++ b/demo/playground/build.gradle.kts @@ -12,7 +12,7 @@ repositories { } kotlin { - + jvmToolchain(11) js(IR) { browser { webpackTask { @@ -30,12 +30,9 @@ kotlin { jvm { // withJava() - compilations.all { - kotlinOptions { - jvmTarget = "11" - freeCompilerArgs = - freeCompilerArgs + "-Xjvm-default=all" + "-Xopt-in=kotlin.RequiresOptIn" + "-Xlambdas=indy" + "-Xcontext-receivers" - } + compilerOptions { + freeCompilerArgs.addAll("-Xjvm-default=all", "-Xopt-in=kotlin.RequiresOptIn", "-Xlambdas=indy", "-Xcontext-receivers") + } testRuns["test"].executionTask.configure { useJUnitPlatform() diff --git a/gradle.properties b/gradle.properties index d4b08331..c4510ba9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,12 +1,7 @@ kotlin.code.style=official kotlin.mpp.stability.nowarn=true -kotlin.js.compiler=ir org.gradle.parallel=true org.gradle.jvmargs=-Xmx4G -org.jetbrains.compose.experimental.jscanvas.enabled=true - -toolsVersion=0.15.2-kotlin-2.0.0-Beta5 -#kotlin.experimental.tryK2=true -#kscience.wasm.disabled=true +toolsVersion=0.15.2-kotlin-2.0.0-RC1 diff --git a/settings.gradle.kts b/settings.gradle.kts index d8cf13ae..069ccb19 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,7 +18,6 @@ pluginManagement { id("space.kscience.gradle.project") version toolsVersion id("space.kscience.gradle.mpp") version toolsVersion id("space.kscience.gradle.jvm") version toolsVersion - id("space.kscience.gradle.js") version toolsVersion } } diff --git a/visionforge-compose-html/README.md b/visionforge-compose-html/README.md new file mode 100644 index 00000000..b84bbf12 --- /dev/null +++ b/visionforge-compose-html/README.md @@ -0,0 +1,21 @@ +# Module visionforge-compose-html + + + +## Usage + +## Artifact: + +The Maven coordinates of this project are `space.kscience:visionforge-compose-html:0.4.1`. + +**Gradle Kotlin DSL:** +```kotlin +repositories { + maven("https://repo.kotlin.link") + mavenCentral() +} + +dependencies { + implementation("space.kscience:visionforge-compose-html:0.4.1") +} +``` diff --git a/visionforge-compose-html/build.gradle.kts b/visionforge-compose-html/build.gradle.kts index 89d7c329..50d9f1e9 100644 --- a/visionforge-compose-html/build.gradle.kts +++ b/visionforge-compose-html/build.gradle.kts @@ -19,12 +19,11 @@ kotlin { } } - jsMain{ + jsMain { dependencies { - api("app.softwork:bootstrap-compose:0.1.15") - api("app.softwork:bootstrap-compose-icons:0.1.15") - implementation(compose.html.svg) - + implementation(npm("bootstrap", "5.3.3")) + implementation(npm(" bootstrap-icons", "1.11.3")) + api("com.benasher44:uuid:0.8.4") api(compose.html.core) } } diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Alerts.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Alerts.kt new file mode 100644 index 00000000..41a3f5da --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Alerts.kt @@ -0,0 +1,57 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.dom.A +import org.jetbrains.compose.web.dom.AttrBuilderContext +import org.jetbrains.compose.web.dom.ContentBuilder +import org.jetbrains.compose.web.dom.Div +import org.w3c.dom.HTMLAnchorElement +import org.w3c.dom.HTMLDivElement + +@Composable +public fun Alert( + color: Color, + dismissible: Boolean = true, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + content: @Composable () -> Unit +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + Div({ + classes("alert", "alert-$color") + if (dismissible) { + needsJS + classes("alert-dismissible") + } + if (classes != null) { + classes(classes = classes) + } + attr("role", "alert") + attrs?.invoke(this) + }) { + content() + } +} + +@Composable +public fun Link( + href: String?, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + content: ContentBuilder +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + A(href, { + classes("alert-link") + if (classes != null) { + classes(classes = classes) + } + attrs?.invoke(this) + }, content) +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Autocomplete.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Autocomplete.kt new file mode 100644 index 00000000..1948d2d3 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Autocomplete.kt @@ -0,0 +1,204 @@ +package bootstrap + +import androidx.compose.runtime.* +import kotlinx.browser.window +import org.jetbrains.compose.web.attributes.builders.InputAttrsScope +import org.jetbrains.compose.web.css.* +import org.jetbrains.compose.web.dom.* +import org.w3c.dom.Element +import org.w3c.dom.HTMLButtonElement +import org.w3c.dom.HTMLDivElement +import org.w3c.dom.events.Event +import org.w3c.dom.events.EventTarget + +private class ReferenceHolder(var ref: T? = null) + +/** + * A TextInput widget with autocomplete/search suggestions that will display below the TextField. This component + * only supports the "controlled" Input variant. This version provides limited support and expects the user to + * listen to all input change events and build the suggestions list. + * + * @param inputAttrs attributes builder. + * @param suggestions Composable to build the list of suggestions to display + */ +@Composable +public fun Autocomplete( + inputAttrs: InputAttrsScope.() -> Unit = {}, + suggestions: ContentBuilder? = null +) { + var itemsVisible by remember { mutableStateOf(false) } + val parentElement = remember { ReferenceHolder() } + + DisposableEffect(true) { + val effect: (Event) -> Unit = { event -> + if (parentElement.ref?.isChild(event.target) != true) { + itemsVisible = false + } + } + window.document.addEventListener("mousedown", effect) + onDispose { + window.document.removeEventListener("mousedown", effect) + } + } + + Div( + attrs = { + style { + padding(0.px) + } + } + ) { + DisposableEffect(true) { + parentElement.ref = scopeElement + onDispose { parentElement.ref = null } + } + + TextInput(attrs = { + inputAttrs() + onInput { + itemsVisible = true + } + }) + + Div( + attrs = { + Style + classes("dropdown-menu") + if (itemsVisible) { + classes("d-block") + } else { + classes("d-none") + } + onClick { + itemsVisible = false + } + style { + padding(0.px) + } + }, + content = suggestions + ) + } +} + +/** + * An AutoComplete that displays Strings as suggestions. + * + * @param value The value for the TextInput + * @param onValueChange callback when the user changes the Input text + * @param inputId The Id to assign to the TextInput + * @param suggestions List of suggestions to be presented to the user + * @param onSelection callback for when the user selects a suggestion. The TextInput value will not be updated until + * this Composable is recomposed with an updated value. + */ +@Composable +public fun Autocomplete( + value: String, + onValueChange: (String) -> Unit, + inputId: String? = null, + suggestions: List = listOf(), + onSelection: (String) -> Unit +) { + Autocomplete( + value = value, + onValueChange = onValueChange, + inputId = inputId, + suggestions = suggestions, + content = { _, item -> + Text(item) + }, + onSelection = { _, item -> onSelection(item) } + ) +} + +/** + * A TextInput widget with autocomplete/search suggestions that will display below the TextField. This component + * only supports the "controlled" Input variant. It uses a callback for displaying suggestion items. + * + * @param value The value for the TextInput + * @param onValueChange callback when the user changes the Input text + * @param inputId The Id to assign to the TextInput + * @param suggestions List of suggestions to be presented to the user + * @param content Composable for rendering item content. The functions first parameter is the items index and the 2nd + * parameter is the item to be rendered. + * @param onSelection callback for when the user selects a suggestion. The TextInput value will not be updated until + * this Composable is recomposed with an updated value. + * + * This function would be better if using a generic type for the List items, content Composable callback parameter, + * and onSelection callback, except 2 issues may be responsible for compiler failures: + * https://github.com/JetBrains/compose-jb/issues/774 + * https://github.com/JetBrains/compose-jb/issues/1226 + */ +@Composable +public fun Autocomplete( + value: String, + onValueChange: (String) -> Unit, + inputId: String? = null, + suggestions: List = listOf(), + content: @Composable ElementScope.(index: Int, item: String) -> Unit, + onSelection: (index: Int, item: String) -> Unit +) { + Autocomplete(inputAttrs = { + inputId?.let { id(inputId) } + value(value) + onInput { + onValueChange(it.value) + } + }) { + Ul(attrs = { + style { + padding(0.px) + margin(0.px) + listStyle("none") + } + }) { + suggestions.forEachIndexed { index, item -> + Li(attrs = { + style { + padding(0.cssRem) + } + }) { + Button(attrs = { + classes("dropdown-item") + onClick { + onSelection(index, item) + } + }) { + content(index, item) + } + if (index < suggestions.lastIndex) { + Hr(attrs = { + style { + margin(0.px) + } + }) + } + } + } + } + } +} + +/** + * Traverses the element tree to see if this Element is a parent of the provided element. + * @param element Element to check if it is a child of this Element + */ +private fun Element.isChild(element: Element): Boolean { + var nextElement: Element? = element + while (nextElement != this && nextElement != null) { + nextElement = nextElement.parentElement + } + return nextElement == this +} + +/** + * Traverses the element tree to see if this Element is a parent of the provided EventTarget. + * @param eventTarget EventTarget to check if it is a child of this Element + */ +private fun Element.isChild(eventTarget: EventTarget?): Boolean { + return if (eventTarget is Element) { + isChild(eventTarget) + } else { + false + } +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Badge.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Badge.kt new file mode 100644 index 00000000..987fe61b --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Badge.kt @@ -0,0 +1,36 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.dom.AttrBuilderContext +import org.jetbrains.compose.web.dom.ContentBuilder +import org.jetbrains.compose.web.dom.Span +import org.w3c.dom.HTMLSpanElement + +@Composable +public fun Badge( + backgroundColor: Color, + textColor: Color? = null, + round: Boolean = false, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + content: ContentBuilder +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Span(attrs = { + classes("badge", backgroundColor.background()) + if (textColor != null) { + classes(textColor.text()) + } + if (round) { + classes("rounded-pill") + } + if (classes != null) { + classes(classes = classes) + } + attrs?.invoke(this) + }, content) +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Box.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Box.kt new file mode 100644 index 00000000..f30fa15b --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Box.kt @@ -0,0 +1,26 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.dom.AttrBuilderContext +import org.jetbrains.compose.web.dom.ContentBuilder +import org.jetbrains.compose.web.dom.Div +import org.w3c.dom.HTMLDivElement + +@Composable +public fun Box( + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + content: ContentBuilder? = null +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Div(attrs = { + if (classes != null) { + classes(classes = classes) + } + attrs?.invoke(this) + }, content = content) +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Brand.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Brand.kt new file mode 100644 index 00000000..23850849 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Brand.kt @@ -0,0 +1,27 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.dom.AttrBuilderContext +import org.jetbrains.compose.web.dom.ContentBuilder +import org.jetbrains.compose.web.dom.Div +import org.w3c.dom.HTMLDivElement + +@Composable +public fun Brand( + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + content: ContentBuilder +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Div(attrs = { + classes("navbar-brand") + if (classes != null) { + classes(classes = classes) + } + attrs?.invoke(this) + }, content) +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Breakpoint.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Breakpoint.kt new file mode 100644 index 00000000..9b7eb64c --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Breakpoint.kt @@ -0,0 +1,29 @@ +package bootstrap + +import org.jetbrains.compose.web.css.CSSLengthValue +import org.jetbrains.compose.web.css.px + +public enum class Breakpoint(private val classInfix: String) { + Small("sm"), + Medium("md"), + Large("lg"), + ExtraLarge("xl"), + ExtraExtraLarge("xxl"); + + override fun toString(): String = classInfix +} + +/** + * Breakpoint thresholds that match Bootstrap defaults. If the defaults were overridden in the + * css (e.g. using sass to generate a custom Bootstrap css), then this variable should be updated with a + * new map that matches before using classes/functions that create + * new styles in a media query. If this is not done, the breakpoints won't be consistent with modified + * bootstrap css classes. + */ +public var breakpoints: Map = mapOf( + Breakpoint.Small to 576.px, + Breakpoint.Medium to 768.px, + Breakpoint.Large to 992.px, + Breakpoint.ExtraLarge to 1200.px, + Breakpoint.ExtraExtraLarge to 1400.px, +) diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Button.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Button.kt new file mode 100644 index 00000000..96fa855e --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Button.kt @@ -0,0 +1,95 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.attributes.ButtonType +import org.jetbrains.compose.web.attributes.disabled +import org.jetbrains.compose.web.attributes.type +import org.jetbrains.compose.web.dom.AttrBuilderContext +import org.jetbrains.compose.web.dom.Button +import org.jetbrains.compose.web.dom.ContentBuilder +import org.jetbrains.compose.web.dom.Text +import org.w3c.dom.HTMLButtonElement + +@Composable +public fun Button( + title: String, + color: Color = Color.Primary, + size: ButtonSize = ButtonSize.Default, + outlined: Boolean = false, + type: ButtonType = ButtonType.Submit, + disabled: Boolean = false, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + action: () -> Unit +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Button(attrs = { + classes("btn") + classes(size.toString()) + if (outlined) { + classes("btn-outline-$color") + } else { + classes("btn-$color") + } + if (disabled) { + disabled() + } + if (classes != null) { + classes(classes = classes) + } + attrs?.invoke(this) + type(type) + onClick { + action() + } + }) { + Text(title) + } +} + +public enum class ButtonSize(private val prefix: String) { + Default("btn"), Large("btn-lg"), Small("btn-sm"); + + override fun toString(): String = prefix +} + +@Composable +public fun Button( + content: ContentBuilder, + color: Color = Color.Primary, + outlined: Boolean = false, + type: ButtonType = ButtonType.Submit, + disabled: Boolean = false, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + action: () -> Unit +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Button(attrs = { + classes("btn") + if (outlined) { + classes("btn-outline-$color") + } else { + classes("btn-$color") + } + if (disabled) { + disabled() + } + if (classes != null) { + classes(classes = classes) + } + attrs?.invoke(this) + type(type) + onClick { + action() + } + }, content) +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/ButtonGroup.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/ButtonGroup.kt new file mode 100644 index 00000000..9ad0d6ee --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/ButtonGroup.kt @@ -0,0 +1,24 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.dom.ContentBuilder +import org.jetbrains.compose.web.dom.Div +import org.w3c.dom.HTMLDivElement + +@Composable +public fun ButtonGroup( + styling: (Styling.() -> Unit)? = null, + content: ContentBuilder +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Div({ + classes("btn-group") + if (classes != null) { + classes(classes = classes) + } + }, content) +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Card.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Card.kt new file mode 100644 index 00000000..f4d18ed8 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Card.kt @@ -0,0 +1,49 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.dom.AttrBuilderContext +import org.jetbrains.compose.web.dom.ContentBuilder +import org.jetbrains.compose.web.dom.Div +import org.w3c.dom.HTMLDivElement + +@Composable +public fun Card( + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + headerAttrs: AttrBuilderContext? = null, + header: ContentBuilder? = null, + footerAttrs: AttrBuilderContext? = null, + footer: ContentBuilder? = null, + bodyAttrs: AttrBuilderContext? = null, + body: ContentBuilder +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Div({ + classes("card") + if (classes != null) { + classes(classes = classes) + } + attrs?.invoke(this) + }) { + header?.let { + Div({ + classes("card-header") + headerAttrs?.invoke(this) + }, header) + } + Div({ + classes("card-body") + bodyAttrs?.invoke(this) + }, body) + footer?.let { + Div({ + classes("card-footer") + footerAttrs?.invoke(this) + }, footer) + } + } +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Checkbox.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Checkbox.kt new file mode 100644 index 00000000..6cc345ae --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Checkbox.kt @@ -0,0 +1,63 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.benasher44.uuid.uuid4 +import org.jetbrains.compose.web.attributes.InputType +import org.jetbrains.compose.web.attributes.builders.InputAttrsScope +import org.jetbrains.compose.web.attributes.disabled +import org.jetbrains.compose.web.attributes.forId +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.Input +import org.jetbrains.compose.web.dom.Label +import org.jetbrains.compose.web.dom.Text + +@Composable +public fun Checkbox( + checked: Boolean, + label: String, + id: String = remember { uuid4().toString() }, + disabled: Boolean = false, + inline: Boolean = false, + switch: Boolean = false, + styling: (Styling.() -> Unit)? = null, + attrs: (InputAttrsScope.() -> Unit)? = null, + onClick: (Boolean) -> Unit +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Div({ + classes(BSClasses.formCheck) + if (classes != null) { + classes(classes = classes) + } + if (inline) { + classes(BSClasses.formCheckInline) + } + if (switch) { + classes(BSClasses.formSwitch) + } + }) { + Input(attrs = { + classes(BSClasses.formCheckInput) + id("_$id") + checked(checked) + onInput { event -> + onClick(event.value) + } + if (disabled) { + disabled() + } + attrs?.invoke(this) + }, type = InputType.Checkbox) + Label(attrs = { + classes(BSClasses.formCheckLabel) + forId("_$id") + }) { + Text(label) + } + } +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Classes.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Classes.kt new file mode 100644 index 00000000..aed00f3e --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Classes.kt @@ -0,0 +1,99 @@ +package bootstrap + +public object BSClasses { + public const val disabled: String = "disabled" + public const val active: String = "active" + + public val align: Align = Align + public val display: Display = Display + + public const val colFormLabel: String = "col-form-label" + public const val collapse: String = "collapse" + public const val formCheck: String = "form-check" + public const val formCheckInline: String = "form-check-inline" + public const val formCheckInput: String = "form-check-input" + public const val formCheckLabel: String = "form-check-label" + public const val formControl: String = "form-control" + public const val formFloating: String = "form-floating" + public const val formLabel: String = "form-label" + public const val formRange: String = "form-range" + public const val formSelect: String = "form-select" + public const val formSelectSmall: String = "form-select-sm" + public const val formSelectLarge: String = "form-select-lg" + public const val formSwitch: String = "form-switch" + public const val formText: String = "form-text" + public const val inputGroup: String = "input-group" + public const val inputGroupLarge: String = "input-group-lg" + public const val inputGroupSmall: String = "input-group-sm" + public const val inputGroupText: String = "input-group-text" + public const val listGroup: String = "list-group" + public const val listGroupFlush: String = "list-group-flush" + public const val listGroupHorizontal: String = "list-group-horizontal" + public const val listGroupItem: String = "list-group-item" + public const val listGroupItemAction: String = "list-group-item-action" + public const val listGroupNumbered: String = "list-group-numbered" + public const val navbar: String = "navbar" + public const val navbarBrand: String = "navbar-brand" + public const val navbarCollapse: String = "navbar-collapse" + public const val navLink: String = "nav-link" + public const val navbarNav: String = "navbar-nav" + public const val navbarToggler: String = "navbar-toggler" + public const val navbarTogglerIcon: String = "navbar-toggler-icon" +} + +public object Align { + public const val baseline: String = "align-baseline" + public const val top: String = "align-top" + public const val middle: String = "align-middle" + public const val bottom: String = "align-bottom" + public const val textTop: String = "align-text-top" + public const val textBottom: String = "align-text-bottom" +} + +public object Display { + public val small: DisplayBreakpoint = DisplayBreakpoint(Breakpoint.Small) + public val medium: DisplayBreakpoint = DisplayBreakpoint(Breakpoint.Medium) + public val extraLarge: DisplayBreakpoint = DisplayBreakpoint(Breakpoint.ExtraLarge) + public val extraExtraLarge: DisplayBreakpoint = DisplayBreakpoint(Breakpoint.ExtraExtraLarge) + + public const val none: String = "d-${DisplayValue.none}" + public const val inline: String = "d-${DisplayValue.inline}" + public const val inlineBlock: String = "d-${DisplayValue.inlineBlock}" + public const val block: String = "d-${DisplayValue.block}" + public const val grid: String = "d-${DisplayValue.grid}" + public const val table: String = "d-${DisplayValue.table}" + public const val tableCell: String = "d-${DisplayValue.tableCell}" + public const val tableRow: String = "d-${DisplayValue.tableRow}" + public const val flex: String = "d-${DisplayValue.flex}" + public const val inlineFlex: String = "d-${DisplayValue.inlineFlex}" +} + +public class DisplayBreakpoint(breakpoint: Breakpoint) { + public val none: String = makeDisplayClass(breakpoint, DisplayValue.none) + public val inline: String = makeDisplayClass(breakpoint, DisplayValue.inline) + public val inlineBlock: String = makeDisplayClass(breakpoint, DisplayValue.inlineBlock) + public val block: String = makeDisplayClass(breakpoint, DisplayValue.block) + public val grid: String = makeDisplayClass(breakpoint, DisplayValue.grid) + public val table: String = makeDisplayClass(breakpoint, DisplayValue.table) + public val tableCell: String = makeDisplayClass(breakpoint, DisplayValue.tableCell) + public val tableRow: String = makeDisplayClass(breakpoint, DisplayValue.tableRow) + public val flex: String = makeDisplayClass(breakpoint, DisplayValue.flex) + public val inlineFlex: String = makeDisplayClass(breakpoint, DisplayValue.inlineFlex) +} + +private fun makeDisplayClass(breakpoint: Breakpoint, value: String): String { + return "d-$breakpoint-$value" +} + +private object DisplayValue { + const val none = "none" + const val inline = "inline" + const val inlineBlock = "inline-block" + const val block = "block" + const val grid = "grid" + const val table = "table" + const val tableCell = "table-cell" + const val tableRow = "table-row" + const val flex = "flex" + const val inlineFlex = "inline-flex" +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/CloseButton.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/CloseButton.kt new file mode 100644 index 00000000..033befe2 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/CloseButton.kt @@ -0,0 +1,26 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.attributes.ButtonType +import org.jetbrains.compose.web.attributes.disabled +import org.jetbrains.compose.web.attributes.type +import org.jetbrains.compose.web.dom.Button + +@Composable +public fun CloseButton( + disabled: Boolean = false, + onClose: () -> Unit +) { + Style + Button({ + type(ButtonType.Button) + classes("btn-close") + attr("aria-label", "Close") + if (disabled) { + disabled() + } + onClick { + onClose() + } + }) +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Collapse.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Collapse.kt new file mode 100644 index 00000000..d20b7d09 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Collapse.kt @@ -0,0 +1,36 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import com.benasher44.uuid.uuid4 +import org.jetbrains.compose.web.attributes.ButtonType +import org.jetbrains.compose.web.dom.AttrBuilderContext +import org.jetbrains.compose.web.dom.ContentBuilder +import org.jetbrains.compose.web.dom.Div +import org.w3c.dom.HTMLButtonElement +import org.w3c.dom.HTMLDivElement + +@Composable +public fun Collapse( + title: String, + id: String = uuid4().toString(), + color: Color = Color.Primary, + buttonAttrs: AttrBuilderContext? = null, + contentAttrs: AttrBuilderContext? = null, + content: ContentBuilder +) { + Style + needsJS + Button(attrs = { + classes("btn", "btn-$color") + attr("data-bs-toggle", "collapse") + attr("data-bs-target", "#_$id") + attr("aria-expanded", "false") + attr("aria-controls", "_$id") + buttonAttrs?.invoke(this) + }, type = ButtonType.Button, title = title) { } + Div({ + classes("collapse") + id("_$id") + contentAttrs?.invoke(this) + }, content) +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Color.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Color.kt new file mode 100644 index 00000000..a6f22e04 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Color.kt @@ -0,0 +1,23 @@ +package bootstrap + +public enum class Color(private val value: String) { + Primary("primary"), + Secondary("secondary"), + Success("success"), + Info("info"), + Warning("warning"), + Danger("danger"), + Light("light"), + Dark("dark"), + Body("body"), + Muted("muted"), + White("white"), + Black50("black-50"), + White50("white-50"), + Transparent("transparent"); + + override fun toString(): String = value + + public fun background(): String = "bg-$value" + public fun text(): String = "text-$value" +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Column.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Column.kt new file mode 100644 index 00000000..d03c5e8b --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Column.kt @@ -0,0 +1,33 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.dom.AttrBuilderContext +import org.jetbrains.compose.web.dom.ContentBuilder +import org.jetbrains.compose.web.dom.Div +import org.w3c.dom.HTMLDivElement + +@Composable +public fun Column( + auto: Boolean = false, + breakpoint: Breakpoint? = null, + size: Int? = null, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + content: ContentBuilder +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Div(attrs = { + classes("col" + (breakpoint?.let { "-$it" } ?: "") + (size?.let { "-$it" } ?: "")) + if (classes != null) { + classes(classes = classes) + } + if (auto) { + classes("col-auto") + } + attrs?.invoke(this) + }, content) +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Container.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Container.kt new file mode 100644 index 00000000..62bc0048 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Container.kt @@ -0,0 +1,40 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.dom.AttrBuilderContext +import org.jetbrains.compose.web.dom.ContentBuilder +import org.jetbrains.compose.web.dom.Div +import org.w3c.dom.HTMLDivElement + +@Composable +public fun Container( + fluid: Boolean = false, + type: Breakpoint? = null, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + content: ContentBuilder? +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Div(attrs = { + if (classes != null) { + classes(classes = classes) + } + when { + fluid -> { + classes("container-fluid") + } + type != null -> { + classes("container-$type") + } + else -> { + classes("container") + } + } + + attrs?.invoke(this) + }, content) +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/DropDown.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/DropDown.kt new file mode 100644 index 00000000..6d6c68ca --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/DropDown.kt @@ -0,0 +1,194 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.benasher44.uuid.uuid4 +import org.jetbrains.compose.web.attributes.AttrsScope +import org.jetbrains.compose.web.dom.* +import org.jetbrains.compose.web.dom.Button +import org.w3c.dom.Element +import org.w3c.dom.HTMLLIElement +import org.w3c.dom.HTMLUListElement + +public class DropDownBuilder(private val scope: ElementScope) : ElementScope by scope { + @Composable + public fun Button(title: String, styling: (Styling.() -> Unit)? = null, onClick: () -> Unit) { + Li { + val buttonClasses = styling?.let { + Styling().apply(it).generate() + } + Button({ + classes("dropdown-item") + if (buttonClasses != null) { + classes(classes = buttonClasses) + } + onClick { onClick() } + }) { + Text(title) + } + } + } + + @Composable + public fun Divider(styling: (Styling.() -> Unit)? = null) { + Li { + val buttonClasses = styling?.let { + Styling().apply(it).generate() + } + Hr { + classes("dropdown-divider") + if (buttonClasses != null) { + classes(classes = buttonClasses) + } + } + } + } + + @Composable + public fun Header(title: String, styling: (Styling.() -> Unit)? = null) { + Li { + val buttonClasses = styling?.let { + Styling().apply(it).generate() + } + H6({ + classes("dropdown-header") + if (buttonClasses != null) { + classes(buttonClasses) + } + }) { + Text(title) + } + } + } + + @Composable + public fun Custom(block: ContentBuilder) { + Li { + block() + } + } +} + +public object DropDown { + public enum class Direction(private val classname: String) { + Down("dropdown"), + Up("dropup"), + Right("dropend"), + Left("dropstart"); + + public override fun toString(): String { + return classname + } + } + + public sealed class MenuAlignment(internal vararg val classes: String) { + public object Start : MenuAlignment("dropdown-menu") + public object End : MenuAlignment("dropdown-menu", "dropdown-menu-end") + public class StartThenEndAtBreakpoint(breakpoint: Breakpoint) : + MenuAlignment("dropdown-menu", "dropdown-menu-$breakpoint-end") + + public class EndThenStartAtBreakpoint(breakpoint: Breakpoint) : + MenuAlignment("dropdown-menu", "dropdown-menu-end", "dropdown-menu-$breakpoint-start") + } +} + +@Composable +public fun DropDown( + title: String, + id: String = remember { "dropdownMenu${uuid4()}" }, + size: ButtonSize = ButtonSize.Default, + color: Color = Color.Primary, + styling: (Styling.() -> Unit)? = null, + direction: DropDown.Direction = DropDown.Direction.Down, + menuAlignment: DropDown.MenuAlignment = DropDown.MenuAlignment.Start, + block: @Composable DropDownBuilder.() -> Unit, +) { + Style + needsJS + needsPopper + val trigger = @Composable { classes: List? -> + Button(attrs = { + classes("btn", "btn-$color", "dropdown-toggle") + classes(size.toString()) + if (classes != null) { + classes(classes) + } + id(id) + attr("data-bs-toggle", "dropdown") + attr("aria-expanded", "false") + responsiveAlignmentAttribute(menuAlignment) + }) { + Text(title) + } + } + + DropDownBase(trigger, id, styling, direction, menuAlignment, block) +} + +@Composable +public fun NavbarDropDown( + title: String, + href: String?, + id: String = remember { "dropdownMenu${uuid4()}" }, + styling: (Styling.() -> Unit)? = null, + direction: DropDown.Direction = DropDown.Direction.Down, + menuAlignment: DropDown.MenuAlignment = DropDown.MenuAlignment.Start, + block: @Composable DropDownBuilder.() -> Unit, +) { + Style + needsJS + val trigger = @Composable { classes: List? -> + A( + attrs = { + classes("nav-link", "dropdown-toggle") + if (classes != null) { + classes(classes) + } + id(id) + attr("data-bs-toggle", "dropdown") + attr("aria-expanded", "false") + responsiveAlignmentAttribute(menuAlignment) + }, + href = href + ) { + Text(title) + } + } + + DropDownBase(trigger, id, styling, direction, menuAlignment, block) +} + +private fun AttrsScope.responsiveAlignmentAttribute(menuAlignment: DropDown.MenuAlignment) { + if (menuAlignment is DropDown.MenuAlignment.EndThenStartAtBreakpoint || + menuAlignment is DropDown.MenuAlignment.StartThenEndAtBreakpoint + ) { + attr("data-bs-display", "static") + } +} + +@Composable +private fun DropDownBase( + triggerElement: @Composable (classes: List?) -> Unit, + id: String, + styling: (Styling.() -> Unit)? = null, + direction: DropDown.Direction, + menuAlignment: DropDown.MenuAlignment, + block: @Composable DropDownBuilder.() -> Unit, +) { + Style + needsJS + val classes = styling?.let { + Styling().apply(it).generate() + } + + Div({ classes("btn-group", direction.toString()) }) { + triggerElement(classes) + + Ul({ + classes(classes = menuAlignment.classes) + attr("aria-labelledby", id) + }) { + DropDownBuilder(this).block() + } + } +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/FormFloating.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/FormFloating.kt new file mode 100644 index 00000000..f89d6b9f --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/FormFloating.kt @@ -0,0 +1,30 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import org.jetbrains.compose.web.dom.AttrBuilderContext +import org.jetbrains.compose.web.dom.ContentBuilder +import org.jetbrains.compose.web.dom.Div +import org.w3c.dom.HTMLDivElement + +@Composable +@NonRestartableComposable +public fun FormFloating( + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + content: ContentBuilder? = null +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + Div(attrs = { + classes("form-floating") + if (classes != null) { + classes(classes = classes) + } + if (attrs != null) { + attrs() + } + }, content) +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/FormLabel.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/FormLabel.kt new file mode 100644 index 00000000..800ff4ec --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/FormLabel.kt @@ -0,0 +1,32 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.dom.AttrBuilderContext +import org.jetbrains.compose.web.dom.ContentBuilder +import org.jetbrains.compose.web.dom.Label +import org.w3c.dom.HTMLLabelElement + +@Composable +public fun FormLabel( + forId: String? = null, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + content: ContentBuilder? = null +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Label( + forId = forId, + attrs = { + classes(BSClasses.formLabel) + if (classes != null) { + classes(classes = classes) + } + attrs?.invoke(this) + }, + content = content + ) +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/GridBox.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/GridBox.kt new file mode 100644 index 00000000..77f2b158 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/GridBox.kt @@ -0,0 +1,773 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.benasher44.uuid.uuid4 +import org.jetbrains.compose.web.css.* +import org.jetbrains.compose.web.dom.* +import org.w3c.dom.HTMLDivElement +import kotlin.reflect.KProperty + +@Composable +public fun GridBox( + styling: @Composable GridStyle.() -> Unit, + attrs: AttrBuilderContext? = null, + content: (@Composable GridContentBuilder.() -> Unit)? = null +) { + Style + val style = GridStyle(styling) + val classes = style.generate() + + Div(attrs = { + classes("d-grid") + classes(classes = classes) + attrs?.invoke(this) + }) { + val scope = remember { GridContentBuilder(this) } + content?.invoke(scope) + } +} + +public class GridContentBuilder(scope: ElementScope) : ElementScope by scope { + public fun Styling.GridItem(spec: GridItemLayout.() -> Unit) { + val s = GridItemLayout().apply(spec) + + this.registerGenerator { + s.generate() + } + } +} + +@Composable +public fun GridStyle(styling: @Composable GridStyle.() -> Unit): GridStyle = GridStyle().apply { styling() } + +public class GridStyle : Styling() { + public val GridLayout: GridLayout = GridLayout() + + @Composable + override fun generate(): List { + return super.generate() + GridLayout.generate() + } +} + +public class GridLayout { + private var columns: MutableList = mutableListOf() + private var rows: MutableList = mutableListOf() + private var areas: MutableList = mutableListOf() + public var gap: CSSLengthOrPercentageValue? = null + public var justifyContent: Placement? = null + public var alignContent: Placement? = null + + public operator fun invoke(f: GridLayout.() -> Unit) { + this.f() + } + + public enum class Placement(private val value: String) { + Start("start"), + End("end"), + Center("center"), + Stretch("stretch"), + SpaceAround("space-around"), + SpaceBetween("space-between"), + SpaceEvenly("space-evenly"); + + public override fun toString(): String { + return value + } + } + + public fun columns(breakpoint: Breakpoint? = null, spec: GridTemplateTrack.() -> Unit) { + columns += GridTemplateTrack(GridTemplateTrack.ColumnOrRow.Column, breakpoint).apply(spec) + } + + public fun rows(breakpoint: Breakpoint? = null, spec: GridTemplateTrack.() -> Unit) { + rows += GridTemplateTrack(GridTemplateTrack.ColumnOrRow.Row, breakpoint).apply(spec) + } + + public fun areas(breakpoint: Breakpoint? = null, spec: GridArea.() -> Unit) { + areas += GridArea(breakpoint).apply(spec) + } + + @Composable + internal fun generate(): List { + val classes: MutableList = mutableListOf() + + Style { + val classname = remember { "_${uuid4()}" } + + className(classname) style { + gap?.let { + property("gap", gap.toString()) + } + justifyContent?.let { + property("justify-content", it.toString()) + } + alignContent?.let { + property("align-content", it.toString()) + } + } + + columns.forEach { + it.apply { classes += generateStyle() } + } + + rows.forEach { + it.apply { classes += generateStyle() } + } + + areas.forEach { + it.apply { classes += generateStyle() } + } + classes += classname + } + + return classes + } +} + +public class GridTemplateTrack internal constructor( + private val type: ColumnOrRow, + private val breakpoint: Breakpoint? +) { + private val items: MutableList = mutableListOf() + public var gap: CSSLengthOrPercentageValue? = null + public var alignment: Alignment? = null + private var auto = false + + internal enum class ColumnOrRow(val gap: String, val align: String) { + Column("column-gap", "align-items"), + Row("row-gap", "justify-items"), + } + + public enum class Alignment(private val value: String) { + Start("start"), + End("end"), + Center("center"), + Stretch("stretch"); + + public override fun toString(): String { + return value + } + } + + public fun none() { + check(items.isEmpty()) { + "Cannot add 'none' to already specified grid-template-columns" + } + items.add(Grid.GridTemplateNone) + } + + /** + * This function is used to implement grid-template-columns/rows when the + * track list follows the syntax. + * See https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template-columns + */ + public fun trackList(block: TrackList.() -> Unit) { + check(items.isEmpty()) { + "Cannot add more tracks" + } + items += TrackList().apply(block).items + } + + /** + * This function is used to implement grid-template-columns/rows when the + * track list follows the syntax. + * See https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template-columns + */ + public fun autoTrackList(block: AutoTrackList.() -> Unit) { + check(items.isEmpty()) { + "Cannot add more tracks" + } + items += AutoTrackList().apply(block).items + } + + /** + * This function is used to implement grid-auto-rows and grid-auto-columns properties. + * See https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template-columns + */ + public fun auto(block: AutoList.() -> Unit) { + check(items.isEmpty()) { + "Cannot add more tracks" + } + items += AutoList().apply(block).items + auto = true + } + + @Composable + internal fun StyleSheetBuilder.generateStyle(): String { + val classname = remember { "_${uuid4()}" } + + withBreakpoint(breakpoint) { + className(classname) style { + val propValue = items.joinToString(separator = " ") + val propName = when { + auto && type == ColumnOrRow.Column -> "grid-auto-columns" + auto && type == ColumnOrRow.Row -> "grid-auto-rows" + !auto && type == ColumnOrRow.Column -> "grid-template-columns" + !auto && type == ColumnOrRow.Row -> "grid-template-rows" + else -> error("not possible") + } + + property(propName, propValue) + + gap?.let { + property(type.gap, it) + } + + alignment?.let { + property(type.align, it.toString()) + } + } + } + + return classname + } +} + +public open class TrackListUnits { + public val Number.ch: Grid.LengthPercentage by LengthPercentageUnitPropertyDelegate(CSSUnit.ch) + public val Number.em: Grid.LengthPercentage by LengthPercentageUnitPropertyDelegate(CSSUnit.em) + public val Number.ex: Grid.LengthPercentage by LengthPercentageUnitPropertyDelegate(CSSUnit.ex) + public val Number.rem: Grid.LengthPercentage by LengthPercentageUnitPropertyDelegate(CSSUnit.rem) + public val Number.vh: Grid.LengthPercentage by LengthPercentageUnitPropertyDelegate(CSSUnit.vh) + public val Number.vw: Grid.LengthPercentage by LengthPercentageUnitPropertyDelegate(CSSUnit.vw) + public val Number.vmin: Grid.LengthPercentage by LengthPercentageUnitPropertyDelegate(CSSUnit.vmin) + public val Number.vmax: Grid.LengthPercentage by LengthPercentageUnitPropertyDelegate(CSSUnit.vmax) + public val Number.px: Grid.LengthPercentage by LengthPercentageUnitPropertyDelegate(CSSUnit.px) + public val Number.cm: Grid.LengthPercentage by LengthPercentageUnitPropertyDelegate(CSSUnit.cm) + public val Number.mm: Grid.LengthPercentage by LengthPercentageUnitPropertyDelegate(CSSUnit.mm) + public val Number.pc: Grid.LengthPercentage by LengthPercentageUnitPropertyDelegate(CSSUnit.pc) + public val Number.pt: Grid.LengthPercentage by LengthPercentageUnitPropertyDelegate(CSSUnit.pt) + public val Number.percent: Grid.LengthPercentage by LengthPercentageUnitPropertyDelegate(CSSUnit.percent) + + public val Number.fr: Grid.Flex + get(): Grid.Flex { + return Grid.Flex(CSSUnitValueTyped(this.toFloat(), CSSUnit.fr)) + } + + private class LengthPercentageUnitPropertyDelegate(val unit: T) { + operator fun getValue(thisRef: Number, property: KProperty<*>): Grid.LengthPercentage { + return Grid.LengthPercentage(CSSUnitValueTyped(thisRef.toFloat(), unit)) + } + } +} + +public class TrackList internal constructor() : TrackListUnits() { + internal val items: MutableList = mutableListOf() + + public fun track(names: Array, size: Grid.TrackSizeItem) { + if (names.isNotEmpty()) { + items += Grid.LineNames(names = names) + } + items += size + } + + public fun track(size: Grid.TrackSizeItem) { + items += size + } + + public fun track(names: Array, repeat: TrackRepeat) { + if (names.isNotEmpty()) { + items += Grid.LineNames(names = names) + } + items += repeat + } + + public fun track(repeat: TrackRepeat) { + items += repeat + } + + public class TrackRepeat(private val count: Int, private vararg val items: Grid.TrackRepeatItem) : + Grid.TrackListItem { + override fun toString(): String { + return "repeat($count, ${items.joinToString(separator = " ")})" + } + } + + public fun lineNames(vararg names: String) { + items += Grid.LineNames(names = names) + } +} + +public class AutoList internal constructor() : TrackListUnits() { + internal val items: MutableList = mutableListOf() + + public fun track(size: Grid.TrackSizeItem) { + items += size + } +} + +public data class MinMax(private val min: Grid.InflexibleBreadthItem, private val max: Grid.TrackBreadthItem) : + Grid.TrackSizeItem { + + override fun toString(): String { + return "minmax($min,$max)" + } +} + +public class AutoTrackList internal constructor() : TrackListUnits() { + internal val items: MutableList = mutableListOf() + private var autoRepeat = false + + public fun track(names: List, size: Grid.FixedSizeItem) { + if (names.isNotEmpty()) { + lineNames(names) + } + items += size + } + + public fun track(size: Grid.FixedSizeItem) { + items += size + } + + public fun track(names: List, repeat: FixedRepeat) { + if (names.isNotEmpty()) { + lineNames(names) + } + items += repeat + } + + public fun track(repeat: FixedRepeat) { + items += repeat + } + + public fun lineNames(names: List) { + items += Grid.LineNames(names = names.toTypedArray()) + } + + public fun lineNames(vararg names: String) { + items += Grid.LineNames(*names) + } + + public fun autoRepeat(type: RepeatType, vararg repeatItems: Grid.FixedRepeatItem) { + if (type == RepeatType.AutoFill || type == RepeatType.AutoFit) { + require(!autoRepeat) { + "auto-repeat already specified, can only be specified once per grid-template-columns" + } + autoRepeat = true + } + + items += AutoRepeat(type, *repeatItems) + } + + public class MinMax private constructor(private val min: Any, private val max: Any) : Grid.FixedSizeItem { + public constructor(minimum: Grid.FixedBreadthItem, maximum: Grid.TrackBreadthItem) : this( + min = minimum, + max = maximum + ) + + public constructor(minimum: Grid.InflexibleBreadthItem, maximum: Grid.FixedBreadthItem) : this( + min = minimum, + max = maximum + ) + + override fun toString(): String { + return "minmax($min,$max)" + } + } + + public class AutoRepeat(private val type: RepeatType, private vararg val items: Grid.FixedRepeatItem) : + Grid.AutoTrackListItem { + override fun toString(): String { + return "repeat($type, ${items.joinToString(separator = " ")})" + } + } + + public enum class RepeatType(private val value: String) { + AutoFill("auto-fill"), + AutoFit("auto-fit"); + + override fun toString(): String { + return value + } + } + + public class FixedRepeat(private val count: Int, private vararg val items: Grid.FixedRepeatItem) : + Grid.AutoTrackListItem { + override fun toString(): String { + return "repeat($count, ${items.joinToString(separator = " ")})" + } + } +} + +public object Grid { + /** + * Marker interface for items that can be included in a Grid-Template-Columns or + * Grid-Template-Rows propery. + */ + public interface GridTemplateItem + + /** + * Marker interface for items that can be included in a . + * see https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template-columns + */ + public interface TrackListItem : GridTemplateItem + + /** + * Marker interface for items that can be included in an . + * see https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template-columns + */ + public interface AutoTrackListItem : GridTemplateItem + + // Marker interfaces for items that can be used as a + public interface TrackSizeItem : TrackListItem, TrackRepeatItem + public interface TrackBreadthItem : TrackSizeItem + public interface TrackRepeatItem + + // Marker interfaces for items that can be used as a + public interface FixedSizeItem : AutoTrackListItem, FixedRepeatItem + public interface FixedBreadthItem : FixedSizeItem + public interface FixedRepeatItem + + public interface InflexibleBreadthItem + + internal object GridTemplateNone : GridTemplateItem { + override fun toString(): String { + return "none" + } + } + + public class LineNames(private vararg val names: String) : + TrackListItem, AutoTrackListItem, TrackRepeatItem, FixedRepeatItem { + override fun toString(): String { + return names.joinToString(prefix = "[", postfix = "]", separator = " ") + } + } + + public data class FitContent(val value: LengthPercentage) : TrackSizeItem { + override fun toString(): String { + return "fit-content($value)" + } + } + + public class LengthPercentage(private val v: CSSLengthOrPercentageValue) : + InflexibleBreadthItem, TrackBreadthItem, FixedBreadthItem { + override fun toString(): String { + return v.toString() + } + } + + public object MinContent : InflexibleBreadthItem, TrackBreadthItem { + override fun toString(): String { + return "min-content" + } + } + + public object MaxContent : InflexibleBreadthItem, TrackBreadthItem { + override fun toString(): String { + return "max-content" + } + } + + public object Auto : InflexibleBreadthItem, TrackBreadthItem { + override fun toString(): String { + return "auto" + } + } + + public class Flex(private val v: CSSSizeValue) : TrackBreadthItem { + override fun toString(): String { + return v.toString() + } + } +} + +public class GridArea internal constructor(private val breakpoint: Breakpoint?) { + private val rows: MutableList> = mutableListOf() + + public fun row(vararg cells: String) { + rows += listOf(*cells) + } + + @Composable + internal fun StyleSheetBuilder.generateStyle(): String { + val classname = remember { "_${uuid4()}" } + + withBreakpoint(breakpoint) { + className(classname) style { + property( + "grid-template-areas", + rows.joinToString(separator = " ") { + it.joinToString(separator = " ", prefix = "\"", postfix = "\"") + } + ) + } + } + + return classname + } +} + +public class GridItemLayout { + private val areaSpecs: MutableList = mutableListOf() + private val placements: MutableList = mutableListOf() + + public fun area(breakpoint: Breakpoint? = null, name: String) { + areaSpecs += GridItemArea(breakpoint, name) + } + + public fun area(name: String) { + areaSpecs += GridItemArea(null, name) + } + + public fun area(breakpoint: Breakpoint, name: String) { + areaSpecs += GridItemArea(breakpoint, name) + } + + public fun area(spec: GridItemArea.() -> Unit) { + areaSpecs += GridItemArea().apply(spec) + } + + public fun area(breakpoint: Breakpoint, spec: GridItemArea.() -> Unit) { + areaSpecs += GridItemArea(breakpoint).apply(spec) + } + + public fun placement(spec: PlacementSpec.() -> Unit) { + placements += PlacementSpec().apply(spec) + } + + public fun placement(breakpoint: Breakpoint, spec: PlacementSpec.() -> Unit) { + placements += PlacementSpec(breakpoint).apply(spec) + } + + @Composable + internal fun generate(): List { + val classes: MutableList = mutableListOf() + + Style { + areaSpecs.forEach { + it.apply { classes += generateStyle() } + } + placements.forEach { + it.apply { classes += generateStyle() } + } + } + + return classes + } +} + +public class GridItemArea(private val breakpoint: Breakpoint? = null, private val name: String? = null) { + private var column: GridItemAreaSpec? = null + private var row: GridItemAreaSpec? = null + + public fun row(f: GridItemAreaSpec.() -> Unit) { + row = GridItemAreaSpec().apply(f) + } + + public fun row(start: Int, end: Int? = null) { + row = GridItemAreaSpec().apply { + start(start) + end?.let { end(it) } + } + } + + public fun row(start: Int, end: GridLine? = null) { + row = GridItemAreaSpec().apply { + start(start) + end(end) + } + } + + public fun row(start: GridLine, end: Int? = null) { + row = GridItemAreaSpec().apply { + start(start) + end?.let { + end(end) + } + } + } + + public fun row(start: GridLine, end: GridLine?) { + row = GridItemAreaSpec().apply { + start(start) + end(end) + } + } + + public fun column(f: GridItemAreaSpec.() -> Unit) { + column = GridItemAreaSpec().apply(f) + } + + public fun column(start: Int, end: Int?) { + column = GridItemAreaSpec().apply { + start(start) + end?.let { + end(it) + } + } + } + + public fun column(start: Int, end: GridLine?) { + column = GridItemAreaSpec().apply { + start(start) + end(end) + } + } + + public fun column(start: GridLine, end: Int?) { + column = GridItemAreaSpec().apply { + start(start) + end?.let { end(it) } + } + } + + public fun column(start: GridLine, end: GridLine) { + column = GridItemAreaSpec().apply { + start(start) + end(end) + } + } + + @Composable + internal fun StyleSheetBuilder.generateStyle(): String { + val classname = remember { "_${uuid4()}" } + + withBreakpoint(breakpoint) { + className(classname) style { + if (name != null) { + property("grid-area", name) + } else { + column?.let { + property("grid-column", it.toString()) + } + row?.let { + property("grid-row", it.toString()) + } + } + } + } + + return classname + } +} + +public class GridItemAreaSpec { + private var startPlacement: GridLine = GridLine.Auto + private var endPlacement: GridLine? = null + + public fun start(ident: String) { + startPlacement = GridLine.CustomIdent(ident) + } + + public fun start(line: Int, ident: String? = null) { + startPlacement = GridLine.Line(line, ident) + } + + public fun end(ident: String) { + endPlacement = GridLine.CustomIdent(ident) + } + + public fun end(line: Int, ident: String? = null) { + endPlacement = GridLine.Line(line, ident) + } + + internal fun start(start: GridLine) { + startPlacement = start + } + + internal fun end(end: GridLine?) { + end?.let { + endPlacement = it + } + } + + public infix fun Int.to(end: Int) { + startPlacement = GridLine.Line(this) + endPlacement = GridLine.Line(end) + } + + public infix fun Int.to(end: GridLine) { + startPlacement = GridLine.Line(this) + endPlacement = end + } + + public infix fun GridLine.to(end: GridLine) { + startPlacement = this + endPlacement = end + } + + public infix fun GridLine.to(end: Int) { + startPlacement = this + endPlacement = GridLine.Line(end) + } + + public override fun toString(): String { + return startPlacement.toString() + (endPlacement?.let { " / $endPlacement" } ?: "") + } +} + +public sealed class GridLine { + public object Auto : GridLine() { + override fun toString(): String { + return "auto" + } + } + + internal data class CustomIdent(private val ident: String) : GridLine() { + override fun toString(): String { + return ident + } + } + + internal data class Line(private val number: Int, private val ident: String? = null) : GridLine() { + override fun toString(): String { + return number.toString() + (ident ?: "") + } + } + + public class Span private constructor(private val number: Int?, private val ident: String?) : + GridLine() { + + public constructor(n: Int) : this(n, null) + public constructor(id: String) : this(null, id) + + override fun toString(): String { + return "span " + (number?.toString() ?: ident) + } + } +} + +public class PlacementSpec(private val breakpoint: Breakpoint? = null) { + public var block: PlacementType = PlacementType.Auto + public var inline: PlacementType? = null + + public enum class PlacementType(private val value: String) { + Auto("auto"), + Start("start"), + End("end"), + Center("center"), + Stretch("stretch"); + + public override fun toString(): String { + return value + } + } + + @Composable + internal fun StyleSheetBuilder.generateStyle(): String { + val classname = remember { "_${uuid4()}" } + + withBreakpoint(breakpoint) { + className(classname) style { + property("place-self", block.toString() + (inline?.let { " $it" } ?: "")) + } + } + + return classname + } +} + +private fun StyleSheetBuilder.withBreakpoint( + breakpoint: Breakpoint?, + block: GenericStyleSheetBuilder.() -> Unit +) { + val bp = breakpoints[breakpoint] + if (bp != null) { + media(mediaMinWidth(bp)) { + block() + } + } else { + block() + } +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Icon.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Icon.kt new file mode 100644 index 00000000..ae7b5db6 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Icon.kt @@ -0,0 +1,30 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.dom.AttrBuilderContext +import org.jetbrains.compose.web.dom.I +import org.w3c.dom.HTMLElement + +/** + * Bootstrap Icon shortcut when using with already embedded icons. + * Alternative use `bootstrap-compose-icons`. + */ +@Composable +public fun Icon( + iconName: String, + styling: (Styling.() -> Unit)? = null, + attrsBuilder: AttrBuilderContext? = null +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + I({ + classes("bi", "bi-$iconName") + if (classes != null) { + classes(classes = classes) + } + attrsBuilder?.invoke(this) + }) +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Input.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Input.kt new file mode 100644 index 00000000..82c66111 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Input.kt @@ -0,0 +1,45 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.attributes.AutoComplete +import org.jetbrains.compose.web.attributes.InputType +import org.jetbrains.compose.web.attributes.autoComplete +import org.jetbrains.compose.web.attributes.builders.InputAttrsScope +import org.jetbrains.compose.web.attributes.placeholder +import org.jetbrains.compose.web.dom.Input +import org.jetbrains.compose.web.dom.Label +import org.jetbrains.compose.web.events.SyntheticInputEvent +import org.w3c.dom.HTMLInputElement +import org.w3c.dom.Text + +@Composable +public fun Input( + label: String, + value: String, + type: InputType, + placeholder: String? = null, + autocomplete: AutoComplete = AutoComplete.off, + labelClasses: String = "form-label", + inputClasses: String = "form-control", + attrs: (InputAttrsScope.() -> Unit)? = null, + onInput: (SyntheticInputEvent) -> Unit +) { + Style + Label(forId = null, attrs = { + classes(labelClasses) + }) { + Text(label) + Input(type = type, attrs = { + autoComplete(autocomplete) + attrs?.invoke(this) + classes(inputClasses) + value(value) + if (placeholder != null) { + placeholder(placeholder) + } + onInput { + onInput(it) + } + }) + } +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/InputGroup.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/InputGroup.kt new file mode 100644 index 00000000..983f4d7e --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/InputGroup.kt @@ -0,0 +1,561 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.remember +import com.benasher44.uuid.uuid4 +import org.jetbrains.compose.web.attributes.* +import org.jetbrains.compose.web.attributes.builders.InputAttrsScope +import org.jetbrains.compose.web.dom.* +import org.jetbrains.compose.web.dom.Text +import org.jetbrains.compose.web.events.SyntheticInputEvent +import org.w3c.dom.* + +@Composable +public fun InputGroup( + inputId: String = remember { "_${uuid4()}" }, + size: InputGroupSize = InputGroupSize.Default, + content: @Composable InputGroupContext.() -> Unit, +) { + Style + val scope = InputGroupContext(inputId) + + Div( + attrs = { + classes(BSClasses.inputGroup) + when (size) { + InputGroupSize.Small -> classes(BSClasses.inputGroupSmall) + InputGroupSize.Large -> classes(BSClasses.inputGroupLarge) + else -> { + } + } + } + ) { + scope.content() + } +} + +public class InputGroupContext(private val inputId: String) { + private fun InputAttrsScope.buildInputAttrs( + disabled: Boolean = false, + autocomplete: AutoComplete = AutoComplete.off, + classes: List?, + attrs: (InputAttrsScope.() -> Unit)? = null, + onInput: (SyntheticInputEvent) -> Unit, + ) { + classes(BSClasses.formControl) + if (classes != null) { + classes(classes) + } + id(inputId) + if (disabled) { + disabled() + } + autoComplete(autocomplete) + attrs?.invoke(this) + onInput { event -> + onInput(event) + } + } + + @Composable + @NonRestartableComposable + public fun Input( + type: InputType, + autocomplete: AutoComplete = AutoComplete.off, + styling: (Styling.() -> Unit)? = null, + attrs: (InputAttrsScope.() -> Unit)? = null, + onInput: (SyntheticInputEvent) -> Unit, + ) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Input( + type = type, + attrs = { + buildInputAttrs(false, autocomplete, classes, attrs, onInput) + } + ) + } + + @Composable + @NonRestartableComposable + public fun DateInput( + value: String, + autocomplete: AutoComplete = AutoComplete.off, + disabled: Boolean = false, + styling: (Styling.() -> Unit)? = null, + attrsBuilder: (InputAttrsScope.() -> Unit)? = null, + onInput: (SyntheticInputEvent) -> Unit, + ) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + org.jetbrains.compose.web.dom.DateInput(value) { + buildInputAttrs(disabled, autocomplete, classes, attrsBuilder, onInput) + } + } + + @Composable + @NonRestartableComposable + public fun DateTimeLocalInput( + value: String, + autocomplete: AutoComplete = AutoComplete.off, + disabled: Boolean = false, + styling: (Styling.() -> Unit)? = null, + attrsBuilder: (InputAttrsScope.() -> Unit)? = null, + onInput: (SyntheticInputEvent) -> Unit, + ) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + org.jetbrains.compose.web.dom.DateTimeLocalInput(value) { + buildInputAttrs(disabled, autocomplete, classes, attrsBuilder, onInput) + } + } + + @Composable + @NonRestartableComposable + public fun EmailInput( + value: String, + autocomplete: AutoComplete = AutoComplete.off, + disabled: Boolean = false, + styling: (Styling.() -> Unit)? = null, + attrsBuilder: (InputAttrsScope.() -> Unit)? = null, + onInput: (SyntheticInputEvent) -> Unit, + ) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + org.jetbrains.compose.web.dom.EmailInput(value) { + buildInputAttrs(disabled, autocomplete, classes, attrsBuilder, onInput) + } + } + + @Composable + @NonRestartableComposable + public fun FileInput( + value: String, + autocomplete: AutoComplete = AutoComplete.off, + disabled: Boolean = false, + styling: (Styling.() -> Unit)? = null, + attrsBuilder: (InputAttrsScope.() -> Unit)? = null, + onInput: (SyntheticInputEvent) -> Unit, + ) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + org.jetbrains.compose.web.dom.FileInput(value) { + buildInputAttrs(disabled, autocomplete, classes, attrsBuilder, onInput) + } + } + + @Composable + @NonRestartableComposable + public fun HiddenInput( + disabled: Boolean = false, + styling: (Styling.() -> Unit)? = null, + attrsBuilder: (InputAttrsScope.() -> Unit)? = null, + onInput: (SyntheticInputEvent) -> Unit, + ) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + org.jetbrains.compose.web.dom.HiddenInput { + buildInputAttrs(disabled, autocomplete = AutoComplete.off, classes, attrsBuilder, onInput) + } + } + + @Composable + @NonRestartableComposable + public fun MonthInput( + value: String, + autocomplete: AutoComplete = AutoComplete.off, + disabled: Boolean = false, + styling: (Styling.() -> Unit)? = null, + attrsBuilder: (InputAttrsScope.() -> Unit)? = null, + onInput: (SyntheticInputEvent) -> Unit, + ) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + org.jetbrains.compose.web.dom.MonthInput(value) { + buildInputAttrs(disabled, autocomplete, classes, attrsBuilder, onInput) + } + } + + @Composable + @NonRestartableComposable + public fun NumberInput( + value: Number?, + autocomplete: AutoComplete = AutoComplete.off, + min: Number? = null, + max: Number? = null, + disabled: Boolean = false, + styling: (Styling.() -> Unit)? = null, + attrsBuilder: (InputAttrsScope.() -> Unit)? = null, + onInput: (SyntheticInputEvent) -> Unit, + ) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + NumberInput(value, min, max) { + buildInputAttrs(disabled, autocomplete, classes, attrsBuilder, onInput) + } + } + + @Composable + @NonRestartableComposable + public fun PasswordInput( + value: String, + autocomplete: AutoComplete = AutoComplete.off, + disabled: Boolean = false, + styling: (Styling.() -> Unit)? = null, + attrsBuilder: (InputAttrsScope.() -> Unit)? = null, + onInput: (SyntheticInputEvent) -> Unit, + ) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + org.jetbrains.compose.web.dom.PasswordInput(value) { + buildInputAttrs(disabled, autocomplete, classes, attrsBuilder, onInput) + } + } + + @Composable + @NonRestartableComposable + public fun SearchInput( + value: String, + autocomplete: AutoComplete = AutoComplete.off, + disabled: Boolean = false, + styling: (Styling.() -> Unit)? = null, + attrsBuilder: (InputAttrsScope.() -> Unit)? = null, + onInput: (SyntheticInputEvent) -> Unit, + ) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + org.jetbrains.compose.web.dom.SearchInput(value) { + buildInputAttrs(disabled, autocomplete, classes, attrsBuilder, onInput) + } + } + + @Composable + @NonRestartableComposable + public fun TelInput( + value: String, + autocomplete: AutoComplete = AutoComplete.off, + disabled: Boolean = false, + styling: (Styling.() -> Unit)? = null, + attrsBuilder: (InputAttrsScope.() -> Unit)? = null, + onInput: (SyntheticInputEvent) -> Unit, + ) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + org.jetbrains.compose.web.dom.TelInput(value) { + buildInputAttrs(disabled, autocomplete, classes, attrsBuilder, onInput) + } + } + + @Composable + public fun TextInput( + value: String, + placeholder: String? = null, + autocomplete: AutoComplete = AutoComplete.off, + disabled: Boolean = false, + styling: (Styling.() -> Unit)? = null, + attrs: (InputAttrsScope.() -> Unit)? = null, + onInput: (SyntheticInputEvent) -> Unit, + ) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + org.jetbrains.compose.web.dom.TextInput(value) { + buildInputAttrs(disabled, autocomplete, classes, attrs, onInput) + placeholder?.let { + placeholder(it) + } + } + } + + @Composable + @NonRestartableComposable + public fun TextAreaInput( + value: String, + autocomplete: AutoComplete = AutoComplete.off, + disabled: Boolean = false, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + onInput: (SyntheticInputEvent) -> Unit, + ) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + TextArea(attrs = { + classes(BSClasses.formControl) + if (classes != null) { + classes(classes = classes) + } + autoComplete(autocomplete) + id(inputId) + if (disabled) { + disabled() + } + attrs?.invoke(this) + onInput { event -> + onInput(event) + } + }, value = value) + } + + @Composable + @NonRestartableComposable + public fun TimeInput( + value: String, + autocomplete: AutoComplete = AutoComplete.off, + disabled: Boolean = false, + styling: (Styling.() -> Unit)? = null, + attrs: (InputAttrsScope.() -> Unit)? = null, + onInput: (SyntheticInputEvent) -> Unit, + ) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + org.jetbrains.compose.web.dom.TimeInput(value) { + buildInputAttrs(disabled, autocomplete, classes, attrs, onInput) + } + } + + @Composable + @NonRestartableComposable + public fun UrlInput( + value: String, + autocomplete: AutoComplete = AutoComplete.off, + disabled: Boolean = false, + styling: (Styling.() -> Unit)? = null, + attrs: (InputAttrsScope.() -> Unit)? = null, + onInput: (SyntheticInputEvent) -> Unit, + ) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + org.jetbrains.compose.web.dom.UrlInput(value) { + buildInputAttrs(disabled, autocomplete, classes, attrs, onInput) + } + } + + @Composable + @NonRestartableComposable + public fun WeekInput( + value: String, + autocomplete: AutoComplete = AutoComplete.off, + disabled: Boolean = false, + styling: (Styling.() -> Unit)? = null, + attrs: (InputAttrsScope.() -> Unit)? = null, + onInput: (SyntheticInputEvent) -> Unit, + ) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + org.jetbrains.compose.web.dom.WeekInput(value) { + buildInputAttrs(disabled, autocomplete, classes, attrs, onInput) + } + } + + @Composable + @NonRestartableComposable + public fun SelectInput( + disabled: Boolean, + autocomplete: AutoComplete = AutoComplete.off, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + onChange: (List) -> Unit, + content: @Composable SelectContext.() -> Unit, + ) { + Style + Select( + disabled = disabled, + id = inputId, + autocomplete = autocomplete, + styling = styling, + attrs = attrs, + onChange = onChange, + content = content + ) + } + + /** + * Implements the Input group add on as text in a Span element. + */ + @Composable + public fun TextAddOn( + text: String, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + ) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Span(attrs = { + classes(BSClasses.inputGroupText) + if (classes != null) { + classes(classes = classes) + } + attrs?.invoke(this) + }) { + Text(text) + } + } + + /** + * Implements the Input group add on as text in a Label element. The label element's forId is set equal to the + * Input's id. An example of this usage is in the Custom select Bootstrap documentation + * at https://getbootstrap.com/docs/5.0/forms/input-group/#custom-select. + */ + @Composable + public fun LabelAddOn( + text: String, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + ) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Label(attrs = { + classes(BSClasses.inputGroupText) + if (classes != null) { + classes(classes = classes) + } + forId(inputId) + attrs?.invoke(this) + }) { + Text(text) + } + } + + @Composable + public fun ButtonAddOn( + title: String, + color: Color = Color.Primary, + type: ButtonType = ButtonType.Submit, + size: ButtonSize = ButtonSize.Default, + disabled: Boolean = false, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + action: () -> Unit, + ) { + Style + Button(title, color, size, false, type, disabled, styling, attrs, action) + } + + @Composable + public fun DropDownAddOn( + title: String, + color: Color = Color.Primary, + size: ButtonSize = ButtonSize.Default, + styling: (Styling.() -> Unit)? = null, + block: @Composable DropDownBuilder.() -> Unit, + ) { + Style + DropDown(title, inputId, size, color, styling, block = block) + } + + @Composable + public fun CheckboxAddOn( + checked: Boolean, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + onClick: (Boolean) -> Unit, + ) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Div(attrs = { + classes(BSClasses.inputGroupText) + if (classes != null) { + classes(classes = classes) + } + attrs?.invoke(this) + }) { + CheckboxInput(checked, attrs = { + onInput { event -> + onClick(event.value) + } + classes(BSClasses.formCheckInput, "mt-0") + }) + } + } + + @Composable + public fun RadioAddOn( + checked: Boolean, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + onClick: (Boolean) -> Unit, + ) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Div(attrs = { + classes(BSClasses.inputGroupText) + if (classes != null) { + classes(classes = classes) + } + attrs?.invoke(this) + }) { + RadioInput(checked, attrs = { + onInput { event -> + onClick(event.value) + } + classes(BSClasses.formCheckInput, "mt-0") + }) + } + } +} + +public enum class InputGroupSize { + Small, + Default, + Large +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/ListGroup.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/ListGroup.kt new file mode 100644 index 00000000..bdeff31d --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/ListGroup.kt @@ -0,0 +1,182 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.attributes.AttrsScope +import org.jetbrains.compose.web.dom.* +import org.w3c.dom.* + +@Composable +public fun ListGroup( + flush: Boolean = false, + listGroupDirection: ListGroupDirection = ListGroupDirection.Vertical, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + content: ContentBuilder? = null +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Ul( + attrs = { + ListGroupAttrs(flush, false, listGroupDirection, attrs) + if (classes != null) { + classes(classes = classes) + } + }, + content = content + ) +} + +@Composable +public fun NumberedListGroup( + flush: Boolean = false, + listGroupDirection: ListGroupDirection = ListGroupDirection.Vertical, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + content: ContentBuilder? = null +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Ol( + attrs = { + ListGroupAttrs(flush, true, listGroupDirection, attrs) + if (classes != null) { + classes(classes = classes) + } + }, + content = content + ) +} + +private fun AttrsScope.ListGroupAttrs( + flush: Boolean = false, + numbered: Boolean = false, + listGroupDirection: ListGroupDirection = ListGroupDirection.Vertical, + attrs: AttrBuilderContext? = null, +) { + classes(BSClasses.listGroup) + if (flush) { + classes(BSClasses.listGroupFlush) + } + if (numbered) { + classes(BSClasses.listGroupNumbered) + } + + if (listGroupDirection is ListGroupDirection.Horizontal) { + classes(listGroupDirection.classname()) + } + attrs?.invoke(this) +} + +@Composable +public fun ListItem( + active: Boolean = false, + disabled: Boolean = false, + background: Color? = null, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + content: ContentBuilder? = null +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Li(attrs = { + ListItemAttrs(active, disabled, false, background, attrs) + if (classes != null) { + classes(classes = classes) + } + }, content = content) +} + +@Composable +public fun AnchorListItem( + href: String? = null, + active: Boolean = false, + disabled: Boolean = false, + background: Color? = null, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + content: ContentBuilder? = null +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + A( + href = href, + attrs = { + ListItemAttrs(active, disabled, true, background, attrs) + if (classes != null) { + classes(classes = classes) + } + }, + content = content + ) +} + +@Composable +public fun ButtonListItem( + active: Boolean = false, + disabled: Boolean = false, + background: Color? = null, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + content: ContentBuilder? = null +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Button(attrs = { + ListItemAttrs(active, disabled, true, background, attrs) + if (classes != null) { + classes(classes = classes) + } + }, content = content) +} + +private fun AttrsScope.ListItemAttrs( + active: Boolean = false, + disabled: Boolean = false, + actionable: Boolean = false, + background: Color? = null, + attrs: AttrBuilderContext? = null, +) { + classes(BSClasses.listGroupItem) + if (active) { + classes(BSClasses.active) + attr("aria-current", "true") + } + if (disabled) { + classes(BSClasses.disabled) + attr("aria-disabled", "true") + } + if (actionable) { + classes(BSClasses.listGroupItemAction) + } + background?.let { + classes("${BSClasses.listGroupItem}-$it") + } + attrs?.invoke(this) +} + +/** + * Specifies a ListGroup's direction, and optionally for the Horizontal variant a breakpoint. + */ +public sealed class ListGroupDirection { + public object Vertical : ListGroupDirection() + public data class Horizontal(val breakpoint: Breakpoint? = null) : ListGroupDirection() { + internal fun classname(): String { + return "${BSClasses.listGroupHorizontal}${breakpoint?.let { "-$it" } ?: ""}" + } + } +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Modal.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Modal.kt new file mode 100644 index 00000000..7f009ff7 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Modal.kt @@ -0,0 +1,84 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import com.benasher44.uuid.uuid4 +import org.jetbrains.compose.web.dom.* +import org.w3c.dom.HTMLDivElement + +@Composable +public fun Modal( + header: String, + size: Breakpoint? = null, + scrollable: Boolean = false, + id: String = uuid4().toString(), + styling: (Styling.() -> Unit)? = null, + onDismissRequest: () -> Unit, + footer: ContentBuilder? = null, + content: ContentBuilder, +) { + Style + needsJS + val classes = styling?.let { + Styling().apply(it).generate() + } + + Div({ + classes("modal") + id("_$id") + tabIndex(-1) + attr("aria-labelledby", "label$id") + attr("aria-hidden", "true") + attr("data-bs-backdrop", "static") + attr("data-bs-keyboard", "false") + }) { + DisposableEffect(true) { + val htmlDivElement: HTMLDivElement = scopeElement + val bsModal = Modal(htmlDivElement) + htmlDivElement.addEventListener("hidePrevented.bs.modal", callback = { _ -> + onDismissRequest() + }) + bsModal.show() + onDispose { + bsModal.hide() + bsModal.dispose() + } + } + + Div({ + classes("modal-dialog") + if (size != null) { + classes("modal-$size") + } + if (scrollable) { + classes("modal-dialog-scrollable") + } + }) { + Div({ + classes("modal-content") + if (classes != null) { + classes(classes = classes) + } + }) { + Div({ classes("modal-header") }) { + H5({ + classes("modal-title") + id("label$id") + }) { + Text(header) + } + Button({ + classes("btn-close") + // attr("data-bs-dismiss", "modal") + attr("aria-label", "Close") + onClick { + onDismissRequest() + } + }) + } + Div({ classes("modal-body") }, content) + Div({ classes("modal-footer") }, footer) + } + } + } +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Navbar.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Navbar.kt new file mode 100644 index 00000000..190f6454 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Navbar.kt @@ -0,0 +1,221 @@ +package bootstrap + +import androidx.compose.runtime.* +import com.benasher44.uuid.uuid4 +import org.jetbrains.compose.web.dom.* +import org.w3c.dom.* + +/** + * Bootstrap Navbar component. This version provides more flexibility than the alternative but is more complex + * to use. For a simpler implementation use the version that takes a brand and navItems as parameters. + * + * @param collapseBehavior Specifies the Navbar's Responsive behavior with use of the .navbar-expand class. + * @param fluid Specifies if the inner container is fluid (container-fluid) or not. + * @param placement Specifies the placement of the navbar + * @param containerBreakpoint Breakpoint for the inner container. + * @param colorScheme Valid values are Color.Light or Color.Dark to set the navbar-dark/light class. + * @param backgroundColor Background color to use, with the bg-* classes. + * @param content Navbar content, placed within the inner container. + */ +@Composable +public fun Navbar( + collapseBehavior: NavbarCollapseBehavior = NavbarCollapseBehavior.Always, + fluid: Boolean = false, + placement: NavbarPlacement = NavbarPlacement.Default, + containerBreakpoint: Breakpoint? = null, + colorScheme: Color = Color.Light, + backgroundColor: Color = Color.Primary, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + content: ContentBuilder? = null, +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Nav(attrs = { + classes( + BSClasses.navbar, + "navbar-$colorScheme", + "bg-$backgroundColor" + ) + when (placement) { + NavbarPlacement.Default -> { + } + + else -> { + classes(placement.toString()) + } + } + + if (classes != null) { + classes(classes = classes) + } + when (collapseBehavior) { + is NavbarCollapseBehavior.Never -> classes("navbar-expand") + is NavbarCollapseBehavior.AtBreakpoint -> classes("navbar-expand-${collapseBehavior.breakpoint}") + is NavbarCollapseBehavior.Always -> { + } // No class needed for "Always" behavior + } + attr("role", "navigation") + attrs?.invoke(this) + }) { + Container(fluid, containerBreakpoint, content = content) + } +} + +/** + * Bootstrap Navbar component that can optionally enable a Toggler and Brand. + * + * @param collapseBehavior Specifies the Navbar's Responsive behavior with use of the .navbar-expand class. + * @param fluid Specifies if the inner container is fluid (container-fluid) or not. + * @param stickyTop if true, shows the navbar at top + * @param containerBreakpoint Breakpoint for the inner container. + * @param colorScheme Valid values are Color.Light or Color.Dark to set the navbar-dark/light class. + * @param backgroundColor Background color to use, with the bg-* classes. + * @param toggler Whether a toggler button should be provided when falling below the breakpoint. + * @param togglerPosition Specifies if the toggler should be placed on the left or right side of the Navbar. + * @param togglerAttrs Additional attributes to set on the toggler button + * @param togglerTargetId Optional id of the toggler, using a random UUID by default + * @param attrs Additional attributes to set on the Navbar + * @param navAttrs Additional attributes to set on the navItems section of the Navbar + * @param brand Composable implementing the brand elements + * @param navItems navigation items, normally comprised of NavbarLink and NavbarDropDown menu items. + */ +@Composable +public fun Navbar( + collapseBehavior: NavbarCollapseBehavior = NavbarCollapseBehavior.Always, + fluid: Boolean = false, + placement: NavbarPlacement = NavbarPlacement.Default, + containerBreakpoint: Breakpoint? = null, + colorScheme: Color = Color.Light, + backgroundColor: Color = Color.Primary, + toggler: Boolean = true, + togglerPosition: TogglerPosition = TogglerPosition.Right, + togglerTargetId: String = remember { "toggler${uuid4()}" }, + togglerAttrs: AttrBuilderContext? = null, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + navAttrs: AttrBuilderContext? = null, + additionalNavContent: ContentBuilder? = null, + brand: ContentBuilder, + navItems: ContentBuilder, +) { + Navbar( + collapseBehavior, + fluid, + placement, + containerBreakpoint, + colorScheme, + backgroundColor, + styling, + attrs + ) { + if (togglerPosition == TogglerPosition.Right) { + brand() + } + + if (toggler) { + Toggler( + target = togglerTargetId, + controls = togglerTargetId, + styling = styling, + attrs = togglerAttrs + ) + NavbarCollapse(togglerTargetId) { + NavbarNav(attrs = { navAttrs?.invoke(this) }) { + navItems() + } + if (additionalNavContent != null) { + additionalNavContent() + } + } + } else { + NavbarNav(attrs = { navAttrs?.invoke(this) }) { + navItems() + } + if (additionalNavContent != null) { + additionalNavContent() + } + } + + if (togglerPosition == TogglerPosition.Left) { + brand() + } + } +} + +/** + * Bootstrap navbar-collapse component to be used with the NavbarToggler. + * @param id The element id. This value should also be used as the target parameter with a NavbarToggler. + */ +@Composable +public fun NavbarCollapse( + id: String, + attrs: AttrBuilderContext? = null, + content: ContentBuilder? = null, +) { + Style + Div(attrs = { + classes(BSClasses.collapse, BSClasses.navbarCollapse) + id(id) + attrs?.invoke(this) + }, content = content) +} + +/** + * Bootstrap navbar-nav component to be used within a Navbar. + */ +@Composable +public fun NavbarNav( + attrs: AttrBuilderContext? = null, + links: ContentBuilder? = null, +) { + Style + Div(attrs = { + classes(BSClasses.navbarNav) + attrs?.invoke(this) + }, content = links) +} + +/** + * Bootstrap nav-link component. + */ +@Composable +public fun NavbarLink( + active: Boolean, + attrs: AttrBuilderContext? = null, + disabled: Boolean = false, + link: String? = null, + content: ContentBuilder? = null, +) { + Style + A(attrs = { + classes(BSClasses.navLink) + if (active) { + classes(BSClasses.active) + attr("aria-current", "page") + } + if (disabled) { + classes(BSClasses.disabled) + } + attrs?.invoke(this) + }, href = link, content = content) +} + +public enum class TogglerPosition { + Left, Right +} + +public sealed class NavbarCollapseBehavior { + public object Never : NavbarCollapseBehavior() + public object Always : NavbarCollapseBehavior() + public data class AtBreakpoint(val breakpoint: Breakpoint) : NavbarCollapseBehavior() +} + +public enum class NavbarPlacement(private val prefix: String) { + Default(""), FixedTop("fixed-top"), FixedBottom("fixed-bottom"), StickyTop("sticky-top"); + + override fun toString(): String = prefix +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/OffCanvas.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/OffCanvas.kt new file mode 100644 index 00000000..68de4df2 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/OffCanvas.kt @@ -0,0 +1,137 @@ +package bootstrap + +import androidx.compose.runtime.* +import org.jetbrains.compose.web.attributes.ButtonType +import org.jetbrains.compose.web.attributes.type +import org.jetbrains.compose.web.dom.Button +import org.jetbrains.compose.web.dom.ContentBuilder +import org.jetbrains.compose.web.dom.Div +import org.w3c.dom.HTMLDivElement + +/** + * State for [Offcanvas] composable component. Provides functions to call + * into Bootstrap's Javascript to show/hide the Offcanvas element. + * + * @param confirmStateChange Optional callback invoked to signal a state change. + */ +public class OffcanvasState(public val confirmStateChange: (Boolean) -> Unit = {}) { + internal var bsOffcanvas: Offcanvas? = null + private var _visible by mutableStateOf(false) + + /** + * The current visibility state. + */ + public val visible: Boolean + get() { + return _visible + } + + /** + * Shows the Offcanvas component. + */ + public fun show() { + _visible = true + confirmStateChange(_visible) + bsOffcanvas?.apply { show() } + } + + /** + * Hides the Offcanvas component. + */ + public fun hide() { + _visible = false + confirmStateChange(_visible) + bsOffcanvas?.apply { hide() } + } +} + +/** + * Creates an Offcanvas component. + * + * @param placement The location for the Offcanvas component. + * @param offcanvasState Control for showing/hiding the Offcanvas component. + * @param headerContent Composable to render content for the header. A close button will always + * be provided on the right. + * @param showHeaderCloseButton If true the default Bootstrap close button will be shown on the right side of the + * header. If false, the user should provide their own control for closing the Offcanvas component. + * @param bodyContent Composable to render content for the body of the Offcanvas component. + */ +@Composable +public fun Offcanvas( + placement: OffcanvasPlacement, + breakpoint: Breakpoint? = null, + offcanvasState: OffcanvasState = remember { OffcanvasState() }, + headerContent: ContentBuilder? = null, + showHeaderCloseButton: Boolean = true, + bodyContent: ContentBuilder? = null +) { + Style + needsJS + Div(attrs = { + if (breakpoint == null) { + classes("offcanvas") + } else { + classes("offcanvas-$breakpoint") + } + classes(placement.toString()) + tabIndex(-1) + }) { + DisposableEffect(true) { + val htmlDivElement = scopeElement + offcanvasState.bsOffcanvas = Offcanvas(htmlDivElement) + + // synchronize state with the offcanvas visibility + htmlDivElement.addEventListener("hidden.bs.offcanvas", callback = { + offcanvasState.hide() + }) + htmlDivElement.addEventListener("shown.bs.offcanvas", callback = { + offcanvasState.show() + }) + + // Set initial state + offcanvasState.bsOffcanvas?.apply { + if (offcanvasState.visible) { + show() + } else { + hide() + } + } + + onDispose { + offcanvasState.bsOffcanvas?.hide() + offcanvasState.bsOffcanvas = null + } + } + + Div(attrs = { + classes("offcanvas-header") + }) { + headerContent?.invoke(this) + if (showHeaderCloseButton) { + Button(attrs = { + type(ButtonType.Button) + classes("btn-close", "text-reset") + attr("aria-label", "Close") + onClick { + offcanvasState.bsOffcanvas?.hide() + } + }) + } + } + Div(attrs = { classes("offcanvas-body") }, content = bodyContent) + } +} + +/** + * Used to specify the location of the Offcanvas component. + */ +public enum class OffcanvasPlacement(private val value: String) { + START("start"), + END("end"), + TOP("top"), + BOTTOM("bottom"); + + public override fun toString(): String { + return "offcanvas-$value" + } +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Pagination.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Pagination.kt new file mode 100644 index 00000000..97d4c2bc --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Pagination.kt @@ -0,0 +1,67 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.dom.* +import org.w3c.dom.HTMLLIElement +import org.w3c.dom.HTMLUListElement + +@Composable +public fun Pagination( + size: PaginationSize = PaginationSize.Normal, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + content: @Composable PaginationScope.() -> Unit +) { + Style + val style = styling?.let { + Styling().apply(it).generate() + } + + Ul(attrs = { + classes("pagination") + classes(size.toString()) + if (style != null) { + classes(classes = style) + } + attrs?.invoke(this) + }) { + PaginationScope().content() + } +} + +public enum class PaginationSize(private val classes: String) { + Normal(""), Small("pagination-sm"), Large("pagination-lg"); + + override fun toString(): String = classes +} + +public class PaginationScope { + @Composable + public fun PageItem(active: Boolean = false, disabled: Boolean = false, content: ContentBuilder) { + Style + Li(attrs = { + classes("page-item") + if (active) { + classes(BSClasses.active) + } + if (disabled) { + classes(BSClasses.disabled) + } + }) { + content() + } + } + + @Composable + public fun PageLink(title: String, onClick: () -> Unit) { + Style + A(attrs = { + classes("page-link") + onClick { + onClick() + } + }) { + Text(title) + } + } +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/README.md b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/README.md new file mode 100644 index 00000000..fac81254 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/README.md @@ -0,0 +1 @@ +This implementation is based on https://github.com/hfhbd/bootstrap-compose, which is distributed under Apache 2.0 license. \ No newline at end of file diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Radio.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Radio.kt new file mode 100644 index 00000000..423211bd --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Radio.kt @@ -0,0 +1,69 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.benasher44.uuid.uuid4 +import org.jetbrains.compose.web.attributes.InputType +import org.jetbrains.compose.web.attributes.disabled +import org.jetbrains.compose.web.attributes.forId +import org.jetbrains.compose.web.attributes.name +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.Input +import org.jetbrains.compose.web.dom.Label +import org.jetbrains.compose.web.dom.Text + +@Composable +public fun RadioGroup(content: @Composable RadioGroupScope.() -> Unit) { + val groupName = remember { "_${uuid4()}" } + val context = RadioGroupScope(groupName) + context.content() +} + +public class RadioGroupScope(private val name: String) { + @Composable + public fun Radio( + label: String, + checked: Boolean = false, + disabled: Boolean = false, + inline: Boolean = false, + styling: (Styling.() -> Unit)? = null, + onClick: (Boolean) -> Unit, + ) { + val id = remember { "_${uuid4()}" } + + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Div(attrs = { + classes(BSClasses.formCheck) + if (inline) { + classes(BSClasses.formCheckInline) + } + if (classes != null) { + classes(classes = classes) + } + }) { + Input(attrs = { + classes(BSClasses.formCheckInput) + id(id) + checked(checked) + + onInput { event -> + onClick(event.value) + } + if (disabled) { + disabled() + } + name(name) + }, type = InputType.Radio) + Label(attrs = { + classes(BSClasses.formCheckLabel) + forId(id) + }) { + Text(label) + } + } + } +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Range.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Range.kt new file mode 100644 index 00000000..b0173227 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Range.kt @@ -0,0 +1,43 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.benasher44.uuid.uuid4 +import org.jetbrains.compose.web.attributes.disabled +import org.jetbrains.compose.web.dom.AttrBuilderContext +import org.jetbrains.compose.web.dom.RangeInput +import org.jetbrains.compose.web.events.SyntheticInputEvent +import org.w3c.dom.HTMLInputElement + +@Composable +public fun Range( + value: Number, + min: Number? = null, + max: Number? = null, + step: Number = 1, + disabled: Boolean = false, + id: String = remember { "_${uuid4()}" }, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + onInput: (SyntheticInputEvent) -> Unit, +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + RangeInput(value, min, max, step) { + if (classes != null) { + classes(classes = classes) + } + onInput { + onInput(it) + } + if (disabled) { + disabled() + } + id(id) + classes(BSClasses.formRange) + attrs?.invoke(this) + } +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Row.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Row.kt new file mode 100644 index 00000000..f2f8b7d1 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Row.kt @@ -0,0 +1,94 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.dom.AttrBuilderContext +import org.jetbrains.compose.web.dom.ContentBuilder +import org.jetbrains.compose.web.dom.Div +import org.w3c.dom.HTMLDivElement + +@Composable +public fun Row( + styling: (@Composable RowStyling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + content: ContentBuilder +) { + Style + val classes = styling?.let { + RowStyling(it).generate() + } + + Div(attrs = { + classes("row") + if (classes != null) { + classes(classes = classes) + } + attrs?.invoke(this) + }, content = content) +} + +@Composable +public fun RowStyling(styling: @Composable RowStyling.() -> Unit): RowStyling = RowStyling().apply { styling() } + +public class RowStyling : Styling() { + public val Gutters: Gutters = Gutters() + + @Composable + override fun generate(): List { + return super.generate() + Gutters.generate() + } +} + +public class Gutters { + public operator fun invoke(f: Gutters.() -> Unit) { + this.f() + } + + @Composable + internal fun generate(): List { + val classes: MutableList = mutableListOf() + + _spec?.let { + classes += it.generateClassStrings() + } + + return classes + } + + public enum class GutterSize(private val value: String) { + None("0"), + ExtraSmall("1"), + Small("2"), + Medium("3"), + Large("4"), + ExtraLarge("5"); + + override fun toString(): String { + return value + } + } + + public enum class Direction(private val value: String) { + Horizontal("x"), + Vertical("y"), + HorizontalAndVertical(""); + + override fun toString(): String { + return value + } + } + + public class GutterSpec(private val direction: Direction) { + public var size: GutterSize = GutterSize.Small + public var breakpoint: Breakpoint? = null + + internal fun generateClassStrings(): String { + return "g$direction-" + (breakpoint?.let { "$it-" } ?: "") + size.toString() + } + } + + private var _spec: GutterSpec? = null + + public operator fun Direction.invoke(spec: GutterSpec.() -> Unit) { + _spec = GutterSpec(this).apply(spec) + } +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Select.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Select.kt new file mode 100644 index 00000000..658fe65e --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Select.kt @@ -0,0 +1,105 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.benasher44.uuid.uuid4 +import org.jetbrains.compose.web.attributes.* +import org.jetbrains.compose.web.dom.AttrBuilderContext +import org.jetbrains.compose.web.dom.ContentBuilder +import org.jetbrains.compose.web.dom.Select +import org.w3c.dom.HTMLOptionElement +import org.w3c.dom.HTMLSelectElement + +@Composable +public fun Select( + multiple: Boolean = false, + size: SelectSize = SelectSize.Default, + rows: Int? = null, + disabled: Boolean = false, + autocomplete: AutoComplete = AutoComplete.off, + id: String = remember { "_${uuid4()}" }, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + onChange: (List) -> Unit, + content: @Composable SelectContext.() -> Unit, +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + Select(attrs = { + id(id) + classes(BSClasses.formSelect) + if (classes != null) { + classes(classes = classes) + } + if (multiple) { + multiple() + } + when (size) { + SelectSize.Small -> classes(BSClasses.formSelectSmall) + SelectSize.Large -> classes(BSClasses.formSelectLarge) + else -> { + } + } + rows?.let { + size(it) + } + if (disabled) { + disabled() + } + autoComplete(autocomplete) + attrs?.invoke(this) + onChange { event -> + val selectElement = event.nativeEvent.target as HTMLSelectElement + val options = selectElement.selectedOptions + val results: MutableList = mutableListOf() + for (i in 0..options.length) { + options.item(i)?.let { + results += (it as HTMLOptionElement).value + } + } + onChange(results) + } + }) { + val scope = SelectContext() + scope.content() + } +} + +public class SelectContext { + @Composable + public fun Option( + value: String, + selected: Boolean? = null, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null, + content: ContentBuilder? = null, + ) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + + org.jetbrains.compose.web.dom.Option( + value = value, + attrs = { + if (selected == true) { + selected() + } + if (classes != null) { + classes(classes = classes) + } + attrs?.invoke(this) + }, + content = content + ) + } +} + +public enum class SelectSize { + Small, + Default, + Large +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Styling.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Styling.kt new file mode 100644 index 00000000..24bf55a1 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Styling.kt @@ -0,0 +1,557 @@ +package bootstrap + +import androidx.compose.runtime.Composable + +@DslMarker +public annotation class StylingMarker + +/** + * Primary entrypoint for the Styling DSL. Component authors may create a subclass to add additional styling + * capabilities to their component, including the dynamic generation of classes by using Compose-web style sheet + * builders. + */ +@StylingMarker +public open class Styling { + private val generators: MutableList<@Composable () -> List> = mutableListOf() + public val Margins: SpacingSpecs = SpacingSpecs("m") + public val Padding: SpacingSpecs = SpacingSpecs("p") + public val Borders: BorderSpec = BorderSpec() + public val Background: Background = Background() + public val Text: Text = Text() + public val Layout: Layout = Layout() + + /** + * This function will generate a list of css class names that should be applied to a component in order + * to implement the styling features specified by the dsl. Subclasses should invoke this method and add + * any additional class names to the returned list. This method is Composable so additional style tags can + * be composed if new css class definitions are required. + * + * @return An array of css class names that are to be applied to the target component. + */ + @Composable + public open fun generate(): List { + val classes: MutableList = mutableListOf() + for (gen in generators) { + classes += gen() + } + + classes += Margins.generateClassStrings() + + Padding.generateClassStrings() + + Borders.generateClassStrings() + + Background.generateClassStrings() + + Text.generateClassStrings() + + Layout.generateClassStrings() + + return classes + } + + /** + * Register a styling generator. The provided function is a Composable that may create new styles and classes, + * and returns an array of classnames that are to be applied to the target component. + */ + public fun registerGenerator(block: @Composable () -> List) { + generators += block + } +} + +@Composable +public fun Styling(styling: @Composable Styling.() -> Unit): Styling = Styling().apply { styling() } + +@StylingMarker +public class SpacingSpecs(private val property: String) { + public operator fun invoke(f: SpacingSpecs.() -> Unit) { + this.f() + } + + public enum class Sides(private val value: String) { + All(""), + Top("t"), + Bottom("b"), + Start("s"), + End("e"), + Vertical("y"), + Horizontal("x"); + + override fun toString(): String { + return value + } + } + + public enum class SpacingSize(private val value: String) { + None("0"), + ExtraSmall("1"), + Small("2"), + Medium("3"), + Large("4"), + ExtraLarge("5"), + Auto("auto"); + + override fun toString(): String { + return value + } + } + + public class SideSpec(private val sides: List) { + public var size: SpacingSize = SpacingSize.Small + public var breakpoint: Breakpoint? = null + + internal fun generateClassStrings(property: String): List { + return sides.map { side -> + "$property$side-" + (breakpoint?.let { "$it-" } ?: "") + "$size" + } + } + } + + private val _specs: MutableList = mutableListOf() + + public operator fun Sides.plus(side: Sides): List { + return listOf(this, side) + } + + public operator fun Sides.invoke(spec: SideSpec.() -> Unit) { + _specs += SideSpec(listOf(this)).apply(spec) + } + + public operator fun List.invoke(spec: SideSpec.() -> Unit) { + _specs += SideSpec(this).apply(spec) + } + + internal fun generateClassStrings(): List { + return _specs.flatMap { spec -> + spec.generateClassStrings(property) + } + } + + public val All: Sides = Sides.All + public val Top: Sides = Sides.Top + public val Bottom: Sides = Sides.Bottom + public val Start: Sides = Sides.Start + public val End: Sides = Sides.End + public val Vertical: Sides = Sides.Vertical + public val Horizontal: Sides = Sides.Horizontal +} + +@StylingMarker +public class BorderSpec { + public operator fun invoke(f: BorderSpec.() -> Unit) { + this.f() + } + + public enum class Sides(private val value: String) { + All("border"), + Top("border-top"), + End("border-end"), + Bottom("border-bottom"), + Start("border-start"); + + override fun toString(): String { + return value + } + } + + public enum class BorderWidth(private val value: String) { + Thinner("border-1"), + Thin("border-2"), + Medium("border-3"), + Thick("border-4"), + Thicker("border-5"); + + override fun toString(): String { + return value + } + } + + public val All: Sides = Sides.All + public val Top: Sides = Sides.Top + public val End: Sides = Sides.End + public val Bottom: Sides = Sides.Bottom + public val Start: Sides = Sides.Start + + public operator fun Sides.plus(side: Sides): List { + return listOf(this, side) + } + + public class SideSpec(private val sides: List) { + public var width: BorderWidth? = null + public var color: Color? = null + + internal fun generateClassString(): List { + val classList: MutableList = mutableListOf() + sides.map { + classList += it.toString() + } + + return classList + } + } + + public enum class BorderRadius(private val value: String) { + All("rounded"), + Top("rounded-top"), + End("rounded-end"), + Bottom("rounded-bottom"), + Start("rounded-start"), + Circle("rounded-circle"), + Pill("rounded-pill"); + + override fun toString(): String { + return value + } + } + + public enum class RadiusSize(private val value: String) { + Small("rounded-1"), + Medium("rounded-2"), + Large("rounded-3"); + + override fun toString(): String { + return value + } + } + + private var sideSpecs: SideSpec? = null + + public operator fun Sides.invoke(spec: SideSpec.() -> Unit) { + sideSpecs = SideSpec(listOf(this)).apply(spec) + } + + public operator fun List.invoke(spec: SideSpec.() -> Unit) { + sideSpecs = SideSpec(this).apply(spec) + } + + private var radiusType: BorderRadius? = null + private var radiusSize: RadiusSize? = null + + public fun radius(type: BorderRadius, size: RadiusSize? = null) { + radiusType = type + radiusSize = size + } + + internal fun generateClassStrings(): List { + val classList: MutableList = mutableListOf() + + sideSpecs?.let { sideSpecs -> + classList += sideSpecs.generateClassString() + + sideSpecs.color?.let { color -> + classList += "border-$color" + } + + sideSpecs.width?.let { width -> + classList += width.toString() + } + } + + classList += listOfNotNull(radiusType, radiusSize).map { + it.toString() + } + + return classList + } +} + +@StylingMarker +public class Background { + public var color: Color? = null + public var gradient: Boolean = false + + public operator fun invoke(f: Background.() -> Unit) { + this.f() + } + + internal fun generateClassStrings(): List { + val classes: MutableList = mutableListOf() + + color?.let { color -> + classes += color.background() + if (gradient) { + classes += "bg-gradient" + } + } + + return classes + } +} + +@StylingMarker +public class Text { + public var color: Color? = null + private val alignments: MutableList = mutableListOf() + public var wrap: Wraps? = null + public var wordBreak: Boolean = false + public var transform: Transform? = null + public var size: Int? = null + public var weight: Weight? = null + public var style: Style? = null + public var lineHeight: LineHeight? = null + public var monospace: Boolean = false + public var reset: Boolean = false + public var muted: Boolean = false + public var decoration: Decoration? = null + + public operator fun invoke(f: Text.() -> Unit) { + this.f() + } + + public enum class Alignment(private val value: String) { + Start("start"), + Center("center"), + End("end"); + + override fun toString(): String { + return value + } + } + + public enum class Wraps(private val value: String) { + Wrap("text-wrap"), + NoWrap("text-nowrap"), + Truncate("text-truncate"); + + override fun toString(): String { + return value + } + } + + public enum class Transform(private val value: String) { + Lowercase("text-lowercase"), + Uppercase("text-uppercase"), + Capitalized("text-capitalize"); + + override fun toString(): String { + return value + } + } + + public enum class Weight(private val value: String) { + Bold("fw-bold"), + Bolder("fw-bolder"), + Normal("fw-normal"), + Light("fw-light"), + Lighter("fw-lighter"); + + override fun toString(): String { + return value + } + } + + public enum class Style(private val value: String) { + Italic("fst-italic"), + Normal("fst-normal"); + + override fun toString(): String { + return value + } + } + + public enum class LineHeight(private val value: String) { + Smallest("lh-1"), + Small("lh-sm"), + Base("lh-base"), + Large("lh-large"); + + override fun toString(): String { + return value + } + } + + public enum class Decoration(private val value: String) { + Underline("text-decoration-underline"), + LineThrough("text-decoration-line-through"), + None("text-decoration-none"); + + override fun toString(): String { + return value + } + } + + public data class AlignSpec(private val alignment: Alignment, private var breakpoint: Breakpoint? = null) { + internal fun className(): String { + return breakpoint?.let { + "text-$it-$alignment" + } ?: "text-$alignment" + } + } + + public fun align(alignment: Alignment, breakpoint: Breakpoint? = null) { + alignments += AlignSpec(alignment, breakpoint) + } + + internal fun generateClassStrings(): List { + val classes: MutableList = mutableListOf() + + color?.let { color -> + classes += color.text() + } + + alignments.forEach { spec -> + classes += spec.className() + } + + if (wordBreak) { + classes += "text-break" + } + + size?.let { + classes += "fs-${it.coerceIn(1, 6)}" + } + + if (monospace) { + classes += "font-monospace" + } + + if (reset) { + classes += "text-reset" + } + + if (muted) { + classes += "text-muted" + } + + decoration?.let { + classes += it.toString() + } + + classes += listOfNotNull(wrap, transform, weight, style, lineHeight).map { + it.toString() + } + + return classes + } +} + +@StylingMarker +public class Layout { + private val displaySpecs: MutableList = mutableListOf() + private val floatSpecs: MutableList = mutableListOf() + public var overflow: Overflow? = null + public var width: Width? = null + public var height: Height? = null + public var verticalAlignment: VerticalAlignment? = null + public var horizontalAlignment: HorizontalAlignment? = null + public var visible: Boolean? = null + + public operator fun invoke(f: Layout.() -> Unit) { + this.f() + } + + public enum class Display(private val value: String) { + None("none"), + Inline("inline"), + InlineBlock("inline-block"), + Block("block"), + Grid("grid"), + Flex("flex"), + InlineFlex("inline-flex"); + + override fun toString(): String { + return value + } + } + + public enum class Float(private val value: String) { + Start("start"), + End("end"), + None("none"); + + override fun toString(): String { + return value + } + } + + public enum class Overflow(private val value: String) { + Auto("overflow-auto"), + Hidden("overflow-hidden"), + Visible("overflow-visible"), + Scroll("overflow-scroll"); + + override fun toString(): String { + return value + } + } + + public enum class Width(private val value: String) { + Quarter("w-25"), + Half("w-50"), + ThreeQuarters("w-75"), + Full("w-100"), + Auto("w-auto"), + View("vw-100"); + + override fun toString(): String { + return value + } + } + + public enum class Height(private val value: String) { + Quarter("h-25"), + Half("h-50"), + ThreeQuarters("h-75"), + Full("h-100"), + Auto("h-auto"), + View("vh-100"); + + override fun toString(): String { + return value + } + } + + public enum class HorizontalAlignment(private val classInfix: String) { + Start("start"), + Center("center"), + End("end"), + Around("around"), + Between("between"), + Evenly("evenly"); + + override fun toString(): String = "justify-content-$classInfix" + } + + public enum class VerticalAlignment(private val value: String) { + Baseline("align-baseline"), + Top("align-top"), + Middle("align-middle"), + Bottom("align-bottom"), + TextTop("align-text-top"), + TextBottom("align-text-bottom"); + + override fun toString(): String = value + } + + private data class DisplaySpec(val display: Display, val breakpoint: Breakpoint?) + private data class FloatSpec(val float: Float, val breakpoint: Breakpoint?) + + public fun display(display: Display, breakpoint: Breakpoint? = null) { + displaySpecs += DisplaySpec(display, breakpoint) + } + + public fun float(float: Float, breakpoint: Breakpoint? = null) { + floatSpecs += FloatSpec(float, breakpoint) + } + + internal fun generateClassStrings(): List { + val classes: MutableList = mutableListOf() + + displaySpecs.forEach { displaySpec -> + classes += "d-" + (displaySpec.breakpoint?.let { "$it-" } ?: "") + "${displaySpec.display}" + } + + floatSpecs.forEach { floatSpec -> + classes += "float-" + (floatSpec.breakpoint?.let { "$it-" } ?: "") + "${floatSpec.float}" + } + + visible?.let { visible -> + classes += if (visible) { + "visible" + } else { + "invisible" + } + } + + classes += listOfNotNull(overflow, width, height, verticalAlignment, horizontalAlignment).map { + it.toString() + } + + return classes + } +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Table.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Table.kt new file mode 100644 index 00000000..1b4eb790 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Table.kt @@ -0,0 +1,449 @@ +package bootstrap + +import androidx.compose.runtime.* +import org.jetbrains.compose.web.attributes.Scope +import org.jetbrains.compose.web.attributes.scope +import org.jetbrains.compose.web.css.* +import org.jetbrains.compose.web.dom.* +import org.w3c.dom.HTMLDivElement +import org.w3c.dom.HTMLTableCaptionElement +import org.w3c.dom.HTMLTableCellElement +import org.w3c.dom.HTMLTableElement +import kotlin.collections.List +import kotlin.collections.all +import kotlin.collections.chunked +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.forEach +import kotlin.collections.forEachIndexed +import kotlin.collections.lastIndex +import kotlin.collections.map +import kotlin.collections.mapIndexed +import kotlin.collections.mutableListOf +import kotlin.collections.mutableMapOf +import kotlin.collections.set +import kotlin.math.max +import kotlin.math.min + +public typealias CurrentPage = Table.Pagination.Page +public typealias PageControl = @Composable ElementScope.( + List>, + CurrentPage, + (Int) -> Unit +) -> Unit + +public object Table { + public data class Column( + val title: String, + val scope: Scope?, + val header: Header, + val cell: Cell, + val footer: Footer? + ) + + public data class Cell internal constructor( + public val color: Color? = null, + val scope: Scope?, + val verticalAlignment: Layout.VerticalAlignment?, + val content: ContentBuilder + ) + + public data class Footer internal constructor( + public val color: Color? = null, + val content: @Composable ElementScope.(List) -> Unit + ) + + public data class Header( + val attrs: AttrBuilderContext? = null, + var content: ContentBuilder? = null + ) { + public constructor( + color: Color, + ) : this(attrs = { classes("table-$color") }) + + public constructor( + color: Color, + content: ContentBuilder? = null + ) : this(attrs = { classes("table-$color") }, content) + } + + public data class Row(val cells: List, public val key: Any?, val color: Color? = null) + + public class Builder internal constructor() { + + private val values = mutableListOf() + + public var rowColor: Color? = null + + internal fun build(): Pair, Color?> = values to rowColor + + public fun column( + title: String, + scope: Scope? = null, + header: Header? = null, + footer: Footer? = null, + cellColor: Color? = null, + verticalAlignment: Layout.VerticalAlignment? = null, + cell: ContentBuilder + ) { + val titledHeader = when { + header != null && header.content == null -> { + header.content = { Text(title) } + header + } + header == null -> { + Header(attrs = null) { Text(title) } + } + else -> header + } + + values.add( + Column( + title = title, + scope = scope, + header = titledHeader, + footer = footer, + cell = Cell(cellColor, scope, verticalAlignment, cell) + ) + ) + } + } + + public class FixedHeaderProperty(public val style: StyleScope.() -> Unit) { + public constructor(topSize: CSSLengthOrPercentageValue, zIndex: ZIndex) : this({ + top(topSize) + property("z-index", zIndex.unsafeCast()) + }) + } + + public interface Pagination { + public enum class Position { + Top, Bottom + } + + public val position: Position? + public val pages: State>> + + public val numberOfButtons: Int + public val entriesPerPageLimit: State? + public val actionNavigateBack: ((CurrentPage, Page) -> Unit)? + public val actionNavigateForward: ((CurrentPage, Page) -> Unit)? + + public val startPageIndex: Int + + public data class Page(val index: Int, val items: List, val numberOfPages: Int) + + public val control: PageControl + + public fun defaultControl(): PageControl = { pages, currentPage, goTo -> + Column { + Pagination(size = PaginationSize.Small) { + PageItem(disabled = currentPage.index == 0) { + PageLink("<") { + val previousIndex = currentPage.index - 1 + actionNavigateBack?.invoke(currentPage, pages[previousIndex]) + goTo(previousIndex) + } + } + + val buttons = tableCalcButtons( + index = currentPage.index, + pages = pages.size, + numberOfButtons = numberOfButtons + ) + + for (index in buttons) { + if (index == currentPage.index) { + PageItem(active = true, disabled = false) { + PageLink("${index + 1}") { } + } + } else { + PageItem { + PageLink("${index + 1}") { + if (index < currentPage.index) { + actionNavigateBack?.invoke(currentPage, pages[index]) + goTo(index) + } else { + actionNavigateForward?.invoke(currentPage, pages[index]) + goTo(index) + } + } + } + } + } + PageItem(disabled = currentPage.index == pages.lastIndex) { + PageLink(">") { + val nextIndex = currentPage.index + 1 + actionNavigateForward?.invoke(currentPage, pages[nextIndex]) + goTo(nextIndex) + } + } + } + } + if (entriesPerPageLimit is MutableState) { + val initLimit = remember { entriesPerPageLimit!!.value } + Column(styling = { + Margins { + End { + size = SpacingSpecs.SpacingSize.Auto + } + } + }, auto = true) { + DropDown("# ${entriesPerPageLimit!!.value}", size = ButtonSize.Small) { + List(4) { + initLimit * (it + 1) + }.forEach { + this.Button("$it") { + (entriesPerPageLimit as MutableState).value = it + } + } + } + } + } + } + } + + public class OffsetPagination( + data: List, + public override val entriesPerPageLimit: State?, + public override val startPageIndex: Int = 0, + public override val position: Pagination.Position? = Pagination.Position.Bottom, + public override val numberOfButtons: Int = 5, + public override val actionNavigateBack: ((CurrentPage, Pagination.Page) -> Unit)? = null, + public override val actionNavigateForward: ((CurrentPage, Pagination.Page) -> Unit)? = null, + ) : Pagination { + + override val pages: State>> = + data.chunked(entriesPerPageLimit?.value ?: data.size).let { + mutableStateOf( + it.mapIndexed { index, data -> + Pagination.Page(index, data, it.size) + } + ) + } + + override var control: PageControl = defaultControl() + + public constructor( + data: List, + entriesPerPageLimit: State, + startPageIndex: Int = 0, + position: Pagination.Position = Pagination.Position.Bottom, + numberOfButtons: Int = 5, + actionNavigateBack: ((CurrentPage, Pagination.Page) -> Unit)? = null, + actionNavigateForward: ((CurrentPage, Pagination.Page) -> Unit)? = null, + control: PageControl + ) : this( + data, + entriesPerPageLimit, + startPageIndex, + position, + numberOfButtons, + actionNavigateBack, + actionNavigateForward + ) { + this.control = control + } + } +} + +internal fun tableCalcButtons(index: Int, pages: Int, numberOfButtons: Int): IntRange { + if (pages <= numberOfButtons) { + return 0 until pages + } + val nr = min(pages, numberOfButtons) + val max = pages - 1 + return when (index) { + 0 -> 0 until nr + (pages - 1) -> (max((pages - nr), 0)) until pages + else -> { + val half = nr / 2 + val lower = max(index - half, 0) + val upper = (min(index + half, max)) + if (lower == 0) { + 0 until nr + } else if (upper == max) { + val newLower = pages - numberOfButtons + newLower until pages + } else { + lower..upper + } + } + } +} + +@Composable +@NonRestartableComposable +public fun Table( + data: List, + key: ((T) -> Any)? = null, + color: Color? = null, + stripedRows: Boolean = false, + stripedColumns: Boolean = false, + hover: Boolean = false, + borderless: Boolean = false, + small: Boolean = false, + fixedHeader: Table.FixedHeaderProperty? = null, + caption: ContentBuilder? = null, + captionTop: Boolean = false, + attrs: AttrBuilderContext? = null, + map: Table.Builder.(Int, T) -> Unit +) { + Table( + pagination = Table.OffsetPagination(data, null, position = null), + key, + color, + stripedRows = stripedRows, + stripedColumns = stripedColumns, + hover, + borderless, + small, + fixedHeader, + caption, + captionTop, + attrs, + map + ) +} + +@Composable +public fun Table( + pagination: Table.Pagination, + key: ((T) -> Any)? = null, + color: Color? = null, + stripedRows: Boolean = false, + stripedColumns: Boolean = false, + hover: Boolean = false, + borderless: Boolean = false, + small: Boolean = false, + fixedHeader: Table.FixedHeaderProperty? = null, + caption: ContentBuilder? = null, + captionTop: Boolean = false, + attrs: AttrBuilderContext? = null, + map: Table.Builder.(Int, T) -> Unit +) { + Style + val headers = mutableMapOf() + val _footers = mutableListOf() + + val pages by pagination.pages + + var currentIndex by remember { mutableStateOf(pagination.startPageIndex) } + val currentPage = pages[min(currentIndex, pages.lastIndex)] + val baseIndex = currentPage.index * (pagination.entriesPerPageLimit?.value ?: 0) + + val rows = currentPage.items.mapIndexed { itemIndexOfPage, item -> + val (columns, rowColor) = Table.Builder().apply { + val index = baseIndex + itemIndexOfPage + map(index, item) + }.build() + val cells = columns.map { + headers[it.title] = it.header + if (it.footer != null) { + _footers.add(it.footer) + } + it.cell + } + Table.Row(color = rowColor, cells = cells, key = key?.invoke(item)) + } + check(rows.all { it.cells.size == headers.size }) + val footers = _footers.takeUnless { it.isEmpty() } + if (footers != null) { + check(rows.all { it.cells.size == footers.size }) + } + + if (pagination.position == Table.Pagination.Position.Top) { + Row { + val control = pagination.control + control(pages, currentPage) { + currentIndex = it + } + } + } + + Table(attrs = { + classes("table") + if (captionTop) { + classes("caption-top") + } + if (small) { + classes("table-sm") + } + color?.let { classes("table-$it") } + if (hover) { + classes("table-hover") + } + if (stripedRows) { + classes("table-striped") + } + if (stripedColumns) { + classes("table-striped-columns") + } + if (borderless) { + classes("table-borderless") + } + attrs?.invoke(this) + }) { + if (caption != null) { + Caption(content = caption) + } + Thead { + Tr { + headers.forEach { (_, header) -> + Th(attrs = { + scope(Scope.Col) + header.attrs?.invoke(this) + if (fixedHeader != null) { + classes("sticky-top") + style { + fixedHeader.style(this) + } + } + }) { + header.content?.invoke(this) + } + } + } + } + Tbody { + for (row in rows) { + key(row.key) { + Tr(attrs = { + row.color?.let { classes("table-$it") } + }) { + for (cell in row.cells) { + Td(attrs = { + cell.color?.let { classes("table-$it") } + cell.scope?.let { scope(it) } + cell.verticalAlignment?.let { classes(it.toString()) } + }) { + cell.content(this) + } + } + } + } + } + } + if (footers != null) { + Tfoot { + Tr { + footers.forEachIndexed { index, cell -> + Td(attrs = { + cell.color?.let { classes("table-$it") } + }) { + cell.content(this, rows[index].cells) + } + } + } + } + } + } + if (pagination.position == Table.Pagination.Position.Bottom) { + Row { + val control = pagination.control + control(pages, currentPage) { + currentIndex = it + } + } + } +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Toasts.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Toasts.kt new file mode 100644 index 00000000..ef320208 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Toasts.kt @@ -0,0 +1,184 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf +import com.benasher44.uuid.Uuid +import com.benasher44.uuid.uuid4 +import org.jetbrains.compose.web.attributes.AttrsScope +import org.jetbrains.compose.web.attributes.ButtonType +import org.jetbrains.compose.web.attributes.type +import org.jetbrains.compose.web.dom.Button +import org.jetbrains.compose.web.dom.ContentBuilder +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.Text +import org.w3c.dom.HTMLButtonElement +import org.w3c.dom.HTMLDivElement +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@Composable +public fun ToastContainer( + toastContainerState: ToastContainerState, + attrs: (AttrsScope.() -> Unit)? = null, +) { + Div(attrs = { + classes("toast-container") + attrs?.invoke(this) + }) { + toastContainerState.toasts.forEach { toastItem -> + key(toastItem) { + Toast(toastItem) + } + } + } +} + +@Composable +private fun Toast(message: ToastContainerState.ToastItem) { + Style + needsJS + Div( + attrs = { + classes("toast") + attr("role", "alert") + attr("data-bs-delay", message.delay.inWholeMilliseconds.toString()) + attr("aria-live", "assertive") + attr("aria-atomic", "true") + message.toastAttrs?.invoke(this) + } + ) { + DisposableEffect(message) { + val htmlDivElement = scopeElement + val bsToast = Toast(htmlDivElement) + htmlDivElement.addEventListener("shown.bs.toast", callback = { + }) + htmlDivElement.addEventListener("hidden.bs.toast", callback = { + message.remove() + }) + bsToast.show() + onDispose { + bsToast.dispose() + message.remove() + htmlDivElement.removeEventListener("shown.bs.toast", callback = { + }) + htmlDivElement.removeEventListener("hidden.bs.toast", callback = { + message.remove() + }) + } + } + message.header?.let { header -> + Div(attrs = { + classes("toast-header") + }) { + header() + if (message.withDismissButton) { + DismissButton(attrs = message.dismissButtonAttrs) + } + } + } + + if (message.header == null && message.withDismissButton) { + Div(attrs = { classes("d-flex") }) { + Div(attrs = { classes("toast-body") }, content = message.body) + DismissButton(styling = { + Margins { + End { + size = SpacingSpecs.SpacingSize.Small + } + All { + size = SpacingSpecs.SpacingSize.Auto + } + } + }) { + message.dismissButtonAttrs?.invoke(this) + } + } + } else { + Div(attrs = { classes("toast-body") }, content = message.body) + } + } +} + +@Composable +private fun DismissButton( + styling: (Styling.() -> Unit)? = null, + attrs: (AttrsScope.() -> Unit)? = null, +) { + val style = styling?.let { + Styling().apply(it).generate() + } + Button(attrs = { + type(ButtonType.Button) + classes("btn-close") + attr("data-bs-dismiss", "toast") + attr("aria-label", "close") + if (style != null) { + classes(classes = style) + } + attrs?.invoke(this) + }) +} + +public class ToastContainerState { + internal val toasts = mutableStateListOf() + + public fun showToast(toastMessage: String) { + showToast(body = { + Text(toastMessage) + }) + } + + /** + * Shows a new Toast. + * @param header Composable to generate the header content + * @param body Composable to generate the body content + * @param withDismissButton Set to True to show a default dismiss button icon, and false to not. + * You can also choose to include your own dismiss button in the [header] or [body] content + * instead of in addition to the default. + * + * @return A function the caller can invoke to dismiss the toast, for example, if you provide your own + * dismiss button in the header or body. + */ + public fun showToast( + withDismissButton: Boolean = true, + delay: Duration = 5.seconds, + toastAttrs: (AttrsScope.() -> Unit)? = null, + dismissButtonAttrs: (AttrsScope.() -> Unit)? = null, + header: ContentBuilder? = null, + body: ContentBuilder, + ): () -> Unit { + val uuid = uuid4() + val toastItem = ToastItem( + uuid, + delay = delay, + withDismissButton, + toastAttrs, + dismissButtonAttrs, + header = header, + body = body + ) { + removeToast(uuid) + } + toasts.add(toastItem) + return { removeToast(uuid) } + } + + private fun removeToast(uuid: Uuid) { + toasts.removeAll { + it.uuid == uuid + } + } + + internal data class ToastItem( + val uuid: Uuid, + val delay: Duration, + val withDismissButton: Boolean, + val toastAttrs: (AttrsScope.() -> Unit)?, + val dismissButtonAttrs: (AttrsScope.() -> Unit)?, + val header: ContentBuilder?, + val body: ContentBuilder?, + val remove: () -> Unit, + ) +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Toggler.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Toggler.kt new file mode 100644 index 00000000..dbbfb0b0 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/Toggler.kt @@ -0,0 +1,34 @@ +package bootstrap + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.dom.AttrBuilderContext +import org.jetbrains.compose.web.dom.Button +import org.jetbrains.compose.web.dom.Span +import org.w3c.dom.HTMLButtonElement + +@Composable +public fun Toggler( + target: String, + controls: String, + styling: (Styling.() -> Unit)? = null, + attrs: AttrBuilderContext? = null +) { + Style + val classes = styling?.let { + Styling().apply(it).generate() + } + Button(attrs = { + classes("navbar-toggler") + if (classes != null) { + classes(classes = classes) + } + attr("data-bs-toggle", "collapse") + attr("data-bs-target", "#$target") + attr("aria-controls", controls) + attr("aria-expanded", "false") + attr("aria-label", "Toggle navigation") + attrs?.invoke(this) + }) { + Span(attrs = { classes("navbar-toggler-icon") }) + } +} diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/ZIndex.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/ZIndex.kt new file mode 100644 index 00000000..2e9e90da --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/ZIndex.kt @@ -0,0 +1,18 @@ +@file:Suppress( + "NAME_CONTAINS_ILLEGAL_CHARS", + "NESTED_CLASS_IN_EXTERNAL_INTERFACE", + "NOTHING_TO_INLINE", +) + +package bootstrap + +// language=JavaScript +@JsName("""(/*union*/{auto: 'auto'}/*union*/)""") +public sealed external interface ZIndex { + public companion object { + public val auto: ZIndex + } +} + +public inline fun ZIndex(value: Int): ZIndex = + value.unsafeCast() diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/bootstrap.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/bootstrap.kt new file mode 100644 index 00000000..4f329be8 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/bootstrap.kt @@ -0,0 +1,24 @@ +@file:JsModule("bootstrap") +@file:JsNonModule + +package bootstrap + +import org.w3c.dom.HTMLDivElement + +internal external class Modal(element: HTMLDivElement) { + internal fun show() + internal fun hide() + internal fun dispose() +} + +internal external class Toast(element: HTMLDivElement) { + internal fun show() + internal fun dispose() +} + +internal external class Offcanvas(element: HTMLDivElement) { + internal fun show() + internal fun hide() +} + +internal external val needsJS: dynamic diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/popper.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/popper.kt new file mode 100644 index 00000000..27c6c937 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/popper.kt @@ -0,0 +1,6 @@ +@file:JsModule("@popperjs/core") +@file:JsNonModule + +package bootstrap + +internal external val needsPopper: dynamic diff --git a/visionforge-compose-html/src/jsMain/kotlin/bootstrap/scss.kt b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/scss.kt new file mode 100644 index 00000000..bd661ef6 --- /dev/null +++ b/visionforge-compose-html/src/jsMain/kotlin/bootstrap/scss.kt @@ -0,0 +1,5 @@ +package bootstrap + +@JsModule("bootstrap/scss/bootstrap.scss") +@JsNonModule +internal external val Style: dynamic diff --git a/visionforge-compose-html/src/jsMain/kotlin/space/kscience/visionforge/html/PropertyEditor.kt b/visionforge-compose-html/src/jsMain/kotlin/space/kscience/visionforge/html/PropertyEditor.kt index 04329625..79aa1825 100644 --- a/visionforge-compose-html/src/jsMain/kotlin/space/kscience/visionforge/html/PropertyEditor.kt +++ b/visionforge-compose-html/src/jsMain/kotlin/space/kscience/visionforge/html/PropertyEditor.kt @@ -1,15 +1,18 @@ package space.kscience.visionforge.html import androidx.compose.runtime.* -import app.softwork.bootstrapcompose.CloseButton import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.launch +import org.jetbrains.compose.web.attributes.ButtonType +import org.jetbrains.compose.web.attributes.disabled +import org.jetbrains.compose.web.attributes.type import org.jetbrains.compose.web.css.AlignItems import org.jetbrains.compose.web.css.alignItems import org.jetbrains.compose.web.css.px import org.jetbrains.compose.web.css.width +import org.jetbrains.compose.web.dom.Button import org.jetbrains.compose.web.dom.Div import org.jetbrains.compose.web.dom.Span import org.jetbrains.compose.web.dom.Text @@ -120,10 +123,16 @@ public fun PropertyEditor( } if (!name.isEmpty()) { - CloseButton(editorPropertyState != EditorPropertyState.Defined) { - rootMeta.remove(name) - update() - } + Button(attrs = { + type(ButtonType.Button) + if(editorPropertyState != EditorPropertyState.Defined) disabled() + classes("btn-close") + onClick { + rootMeta.remove(name) + update() + + } + }) } } if (expanded) { diff --git a/visionforge-compose-html/src/jsMain/kotlin/space/kscience/visionforge/html/Tabs.kt b/visionforge-compose-html/src/jsMain/kotlin/space/kscience/visionforge/html/Tabs.kt index 36504cf4..0c9b537b 100644 --- a/visionforge-compose-html/src/jsMain/kotlin/space/kscience/visionforge/html/Tabs.kt +++ b/visionforge-compose-html/src/jsMain/kotlin/space/kscience/visionforge/html/Tabs.kt @@ -1,9 +1,9 @@ package space.kscience.visionforge.html import androidx.compose.runtime.* -import app.softwork.bootstrapcompose.Card -import app.softwork.bootstrapcompose.NavbarLink -import app.softwork.bootstrapcompose.Styling +import bootstrap.Card +import bootstrap.NavbarLink +import bootstrap.Styling import org.jetbrains.compose.web.css.overflowY import org.jetbrains.compose.web.dom.* import org.w3c.dom.HTMLAnchorElement diff --git a/visionforge-compose-html/src/jsMain/kotlin/space/kscience/visionforge/html/valueChooser.kt b/visionforge-compose-html/src/jsMain/kotlin/space/kscience/visionforge/html/valueChooser.kt index fdec8c3f..96e1ac37 100644 --- a/visionforge-compose-html/src/jsMain/kotlin/space/kscience/visionforge/html/valueChooser.kt +++ b/visionforge-compose-html/src/jsMain/kotlin/space/kscience/visionforge/html/valueChooser.kt @@ -3,8 +3,7 @@ package space.kscience.visionforge.html import androidx.compose.runtime.* -import kotlinx.uuid.UUID -import kotlinx.uuid.generateUUID +import com.benasher44.uuid.uuid4 import org.jetbrains.compose.web.attributes.* import org.jetbrains.compose.web.dom.* import org.w3c.dom.HTMLOptionElement @@ -45,7 +44,7 @@ public fun BooleanValueChooser( value: Value?, onValueChange: (Value?) -> Unit, ) { - val uid = remember { "checkbox[${UUID.generateUUID().toString(false)}]" } + val uid = remember { "checkbox[${uuid4()}]" } var innerValue by remember(value, descriptor) { mutableStateOf( value?.boolean ?: descriptor?.defaultValue?.boolean diff --git a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionGroup.kt b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionGroup.kt index fd8aaa16..d2280b3f 100644 --- a/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionGroup.kt +++ b/visionforge-core/src/commonMain/kotlin/space/kscience/visionforge/VisionGroup.kt @@ -19,7 +19,7 @@ public interface VisionGroup : Vision { override fun update(change: VisionChange) { change.children?.forEach { (name, change) -> - if (change.vision != null || change.vision == NullVision) { + if (change.vision != null) { error("VisionGroup is read-only") } else { children.getChild(name)?.update(change) diff --git a/visionforge-markdown/src/jsMain/kotlin/space/kscience/visionforge/markup/MarkupPlugin.kt b/visionforge-markdown/src/jsMain/kotlin/space/kscience/visionforge/markup/MarkupPlugin.kt index 7ec33c80..e9e0e3bb 100644 --- a/visionforge-markdown/src/jsMain/kotlin/space/kscience/visionforge/markup/MarkupPlugin.kt +++ b/visionforge-markdown/src/jsMain/kotlin/space/kscience/visionforge/markup/MarkupPlugin.kt @@ -23,8 +23,8 @@ import space.kscience.visionforge.useProperty public actual class MarkupPlugin : VisionPlugin(), ElementVisionRenderer { public val visionClient: JsVisionClient by require(JsVisionClient) - override val tag: PluginTag get() = Companion.tag - override val visionSerializersModule: SerializersModule get() = markupSerializersModule + actual override val tag: PluginTag get() = Companion.tag + actual override val visionSerializersModule: SerializersModule get() = markupSerializersModule override fun rateVision(vision: Vision): Int = when (vision) { is VisionOfMarkup -> ElementVisionRenderer.DEFAULT_RATING @@ -57,9 +57,9 @@ public actual class MarkupPlugin : VisionPlugin(), ElementVisionRenderer { } public actual companion object : PluginFactory { - override val tag: PluginTag = PluginTag("vision.markup.js", PluginTag.DATAFORGE_GROUP) + actual override val tag: PluginTag = PluginTag("vision.markup.js", PluginTag.DATAFORGE_GROUP) - override fun build(context: Context, meta: Meta): MarkupPlugin = MarkupPlugin() + actual override fun build(context: Context, meta: Meta): MarkupPlugin = MarkupPlugin() } } \ No newline at end of file diff --git a/visionforge-threejs/src/jsMain/kotlin/space/kscience/visionforge/solid/three/compose/ThreeControls.kt b/visionforge-threejs/src/jsMain/kotlin/space/kscience/visionforge/solid/three/compose/ThreeControls.kt index a0211a1e..b6c6185c 100644 --- a/visionforge-threejs/src/jsMain/kotlin/space/kscience/visionforge/solid/three/compose/ThreeControls.kt +++ b/visionforge-threejs/src/jsMain/kotlin/space/kscience/visionforge/solid/three/compose/ThreeControls.kt @@ -1,11 +1,9 @@ package space.kscience.visionforge.solid.three.compose import androidx.compose.runtime.Composable -import app.softwork.bootstrapcompose.Button -import app.softwork.bootstrapcompose.Color.Info -import app.softwork.bootstrapcompose.Column -import app.softwork.bootstrapcompose.Layout.Height -import app.softwork.bootstrapcompose.Layout.Width +import bootstrap.Button +import bootstrap.Color +import bootstrap.Column import org.jetbrains.compose.web.dom.Hr import org.w3c.files.Blob import org.w3c.files.BlobPropertyBag @@ -22,7 +20,7 @@ internal fun CanvasControls( ) { Column { vision?.let { vision -> - Button("Export", color = Info, styling = { Layout.width = Width.Full }) { + Button("Export", color = Color.Info, styling = { Layout.width = bootstrap.Layout.Width.Full }) { val json = vision.encodeToString() val fileSaver = kotlinext.js.require("file-saver") @@ -51,7 +49,7 @@ public fun ThreeControls( ) { Tabs( styling = { - Layout.height = Height.Full + Layout.height = bootstrap.Layout.Height.Full } ) { vision?.let { vision -> diff --git a/visionforge-threejs/src/jsMain/kotlin/space/kscience/visionforge/solid/three/compose/ThreeView.kt b/visionforge-threejs/src/jsMain/kotlin/space/kscience/visionforge/solid/three/compose/ThreeView.kt index 2dbd1f48..8252589d 100644 --- a/visionforge-threejs/src/jsMain/kotlin/space/kscience/visionforge/solid/three/compose/ThreeView.kt +++ b/visionforge-threejs/src/jsMain/kotlin/space/kscience/visionforge/solid/three/compose/ThreeView.kt @@ -1,11 +1,9 @@ package space.kscience.visionforge.solid.three.compose import androidx.compose.runtime.* -import app.softwork.bootstrapcompose.Card -import app.softwork.bootstrapcompose.Column -import app.softwork.bootstrapcompose.Layout.Height -import app.softwork.bootstrapcompose.Layout.Width -import app.softwork.bootstrapcompose.Row +import bootstrap.Card +import bootstrap.Column +import bootstrap.Row import kotlinx.dom.clear import org.jetbrains.compose.web.css.* import org.jetbrains.compose.web.dom.* @@ -83,15 +81,15 @@ public fun ThreeView( Row( styling = { Layout { - width = Width.Full - height = Height.Full + width = bootstrap.Layout.Width.Full + height = bootstrap.Layout.Height.Full } } ) { Column( styling = { Layout { - height = Height.Full + height = bootstrap.Layout.Height.Full } }, attrs = { @@ -166,7 +164,7 @@ public fun ThreeView( auto = true, styling = { Layout { - height = Height.Full + height = bootstrap.Layout.Height.Full } }, attrs = {