
Type-safe DSL for building HTML5 and SVG DOM trees; reactive MVVM utilities with async-friendly state flows; idiomatic extensions for array, map and set collections, plain-object and DOM helpers.
Kotlin Multiplatform library providing type-safe DSL for constructing HTML5 and SVG DOM trees, along with reactive web utilities, targeting JavaScript and WebAssembly runtimes.
A general set of JavaScript utilities for Kotlin/JS and Kotlin/WASM, including idiomatic Kotlin extensions
for JavaScript collections (JsArray, JsMap, JsSet), plain JS objects, DOM utilities,
and a DOM tree builder DSL.
In build.gradle.kts add:
dependencies {
implementation("com.xemantic.kotlin:xemantic-kotlin-js:0.1")
}The globalThis property provides access to the JavaScript
globalThis
object as a dynamic value:
val value = globalThis.someGlobalProperty
globalThis.myFlag = trueThe JsObject external interface represents a plain JavaScript
object ({}), with operator extensions for bracket access:
val obj = JsObject()
obj["name"] = "Alice"
obj["age"] = 30
val name: String = obj["name"] // "Alice"
obj.isEmpty() // false
obj.isNotEmpty() // true
obj.isNullOrEmpty() // falseKotlin's experimental JsArray, JsMap, and JsSet types lack many common operations.
This library adds idiomatic Kotlin extensions for all three.
val array = jsArrayOf(1, 2, 3)
array.length // 3
array[0] // 1
array[1] = 10 // indexed set
array += 4 // push via += operator
array.push(5) // push
array.map { it * 2 } // JsArray(2, 20, 6, 8, 10)
array.join(", ") // "1, 10, 3, 4, 5"
array.isEmpty() // falseval map = JsMap<String, Int>()
map["x"] = 1
map["y"] = 2
map["x"] // 1
map.size // 2
map.isEmpty() // falseval set = jsSetOf("a", "b", "c")
"a" in set // true (contains operator)
"z" in set // false
set.size // 3
set.isEmpty() // falseThe forEach extension on ItemArrayLike<T> enables
iteration over DOM collections like NamedNodeMap and NodeList:
element.attributes.forEach { attr ->
println("${attr.name}=${attr.value}")
}
element.childNodes.forEach { node ->
println(node.nodeName)
}The test suite includes a full MVVM (Model-View-ViewModel) example demonstrating how to structure a Kotlin multiplatform application with a clear separation between platform-independent logic and browser-specific view code.
The ViewModel and service interfaces live in commonTest, while the View lives in jsTest. This split is intentional:
commonTest - The LoginViewModel, service interfaces (Authenticator, Navigator), and all ViewModel tests are pure Kotlin with no DOM dependency. They can run on any platform (JVM, Native, JS, WASM), enabling fast feedback loops during development (e.g., running tests on JVM without a browser).jsTest - The LoginView uses the DOM DSL to build the actual HTML tree and bind it to the ViewModel. It can only run in a browser environment.This separation means the bulk of your business logic and its tests remain portable and fast to execute, while only the thin view layer requires a browser.
commonTest)interface Authenticator {
suspend fun authenticate(username: String, password: String): Boolean
}
interface Navigator {
fun goTo(location: String)
}commonTest)The LoginViewModel exposes reactive state via StateFlow and delegates side effects to injected services. The CoroutineDispatcher is injected to decouple the ViewModel from any specific threading model:
| Platform | Dispatcher | Why |
|---|---|---|
| Kotlin/JS & WASM |
Dispatchers.Default or Dispatchers.Main
|
JavaScript is single-threaded — both map to the same event-loop dispatcher. |
| Android |
Dispatchers.Main (or .immediate) |
StateFlow updates must be emitted on the UI thread to safely drive view updates. |
| iOS (Kotlin/Native) | Dispatchers.Main |
Maps to the main dispatch queue, same reasoning as Android. |
| Tests | UnconfinedTestDispatcher |
Executes coroutines eagerly and deterministically, without a real event loop. |
class LoginViewModel(
dispatcher: CoroutineDispatcher,
private val authenticator: Authenticator,
private val navigator: Navigator
) {
val submitEnabled: StateFlow<Boolean>
field = MutableStateFlow(false)
val error: StateFlow<String?>
field = MutableStateFlow<String?>(null)
val loading: StateFlow<Boolean>
field = MutableStateFlow(false)
fun onUsernameChanged(username: String) { /* ... */ }
fun onPasswordChanged(password: String) { /* ... */ }
fun onSubmit() { /* launches coroutine to authenticate */ }
fun onCleared() { scope.cancel() }
}commonTest)Tests use Mokkery for mocking and kotlinx-coroutines-test for deterministic coroutine execution. No browser needed:
@Test
fun `should log in on successful authentication`() = runTest {
val dispatcher = UnconfinedTestDispatcher(testScheduler)
val authenticator = mock<Authenticator> {
everySuspend { authenticate("foo", "bar") } returns true
}
val navigator = mock<Navigator>(MockMode.autoUnit)
viewModel = LoginViewModel(dispatcher, authenticator, navigator)
viewModel.onUsernameChanged("foo")
viewModel.onPasswordChanged("bar")
viewModel.onSubmit()
assert(!viewModel.submitEnabled.value)
assert(!viewModel.loading.value)
verifySuspend(VerifyMode.exhaustiveOrder) {
authenticator.authenticate("foo", "bar")
navigator.goTo("Home")
}
}jsTest)The LoginView uses the DOM DSL to build HTML and binds ViewModel state flows directly to DOM properties:
fun loginView(viewModel: LoginViewModel) = node { form("app-login") {
it.onsubmit = { event -> event.preventDefault() }
div("field label border round prefix") {
icon("mail")
input("large border", name = "username", type = "text") { input ->
input.oninput = {
viewModel.onUsernameChanged(input.value)
}
}
label { +"Username" }
}
// ...
nav("no-space") {
button("large", type = "submit") { button ->
button.ariaLabel = "Submit"
viewModel.submitEnabled.onEach { enabled ->
button.disabled = !enabled
}.launchIn(viewModel.scope)
button.onclick = { viewModel.onSubmit() }
+"Submit"
}
}
}}The view function returns a DOM node that can be appended to the document. ViewModel StateFlows are collected with onEach { ... }.launchIn(viewModel.scope) to reactively update DOM properties like disabled and hidden.
All the gradle dependencies are managed by the libs.versions.toml file in the gradle dir.
It is easy to check for the latest version by running:
./gradlew dependencyUpdates --no-parallelKotlin Multiplatform library providing type-safe DSL for constructing HTML5 and SVG DOM trees, along with reactive web utilities, targeting JavaScript and WebAssembly runtimes.
A general set of JavaScript utilities for Kotlin/JS and Kotlin/WASM, including idiomatic Kotlin extensions
for JavaScript collections (JsArray, JsMap, JsSet), plain JS objects, DOM utilities,
and a DOM tree builder DSL.
In build.gradle.kts add:
dependencies {
implementation("com.xemantic.kotlin:xemantic-kotlin-js:0.1")
}The globalThis property provides access to the JavaScript
globalThis
object as a dynamic value:
val value = globalThis.someGlobalProperty
globalThis.myFlag = trueThe JsObject external interface represents a plain JavaScript
object ({}), with operator extensions for bracket access:
val obj = JsObject()
obj["name"] = "Alice"
obj["age"] = 30
val name: String = obj["name"] // "Alice"
obj.isEmpty() // false
obj.isNotEmpty() // true
obj.isNullOrEmpty() // falseKotlin's experimental JsArray, JsMap, and JsSet types lack many common operations.
This library adds idiomatic Kotlin extensions for all three.
val array = jsArrayOf(1, 2, 3)
array.length // 3
array[0] // 1
array[1] = 10 // indexed set
array += 4 // push via += operator
array.push(5) // push
array.map { it * 2 } // JsArray(2, 20, 6, 8, 10)
array.join(", ") // "1, 10, 3, 4, 5"
array.isEmpty() // falseval map = JsMap<String, Int>()
map["x"] = 1
map["y"] = 2
map["x"] // 1
map.size // 2
map.isEmpty() // falseval set = jsSetOf("a", "b", "c")
"a" in set // true (contains operator)
"z" in set // false
set.size // 3
set.isEmpty() // falseThe forEach extension on ItemArrayLike<T> enables
iteration over DOM collections like NamedNodeMap and NodeList:
element.attributes.forEach { attr ->
println("${attr.name}=${attr.value}")
}
element.childNodes.forEach { node ->
println(node.nodeName)
}The test suite includes a full MVVM (Model-View-ViewModel) example demonstrating how to structure a Kotlin multiplatform application with a clear separation between platform-independent logic and browser-specific view code.
The ViewModel and service interfaces live in commonTest, while the View lives in jsTest. This split is intentional:
commonTest - The LoginViewModel, service interfaces (Authenticator, Navigator), and all ViewModel tests are pure Kotlin with no DOM dependency. They can run on any platform (JVM, Native, JS, WASM), enabling fast feedback loops during development (e.g., running tests on JVM without a browser).jsTest - The LoginView uses the DOM DSL to build the actual HTML tree and bind it to the ViewModel. It can only run in a browser environment.This separation means the bulk of your business logic and its tests remain portable and fast to execute, while only the thin view layer requires a browser.
commonTest)interface Authenticator {
suspend fun authenticate(username: String, password: String): Boolean
}
interface Navigator {
fun goTo(location: String)
}commonTest)The LoginViewModel exposes reactive state via StateFlow and delegates side effects to injected services. The CoroutineDispatcher is injected to decouple the ViewModel from any specific threading model:
| Platform | Dispatcher | Why |
|---|---|---|
| Kotlin/JS & WASM |
Dispatchers.Default or Dispatchers.Main
|
JavaScript is single-threaded — both map to the same event-loop dispatcher. |
| Android |
Dispatchers.Main (or .immediate) |
StateFlow updates must be emitted on the UI thread to safely drive view updates. |
| iOS (Kotlin/Native) | Dispatchers.Main |
Maps to the main dispatch queue, same reasoning as Android. |
| Tests | UnconfinedTestDispatcher |
Executes coroutines eagerly and deterministically, without a real event loop. |
class LoginViewModel(
dispatcher: CoroutineDispatcher,
private val authenticator: Authenticator,
private val navigator: Navigator
) {
val submitEnabled: StateFlow<Boolean>
field = MutableStateFlow(false)
val error: StateFlow<String?>
field = MutableStateFlow<String?>(null)
val loading: StateFlow<Boolean>
field = MutableStateFlow(false)
fun onUsernameChanged(username: String) { /* ... */ }
fun onPasswordChanged(password: String) { /* ... */ }
fun onSubmit() { /* launches coroutine to authenticate */ }
fun onCleared() { scope.cancel() }
}commonTest)Tests use Mokkery for mocking and kotlinx-coroutines-test for deterministic coroutine execution. No browser needed:
@Test
fun `should log in on successful authentication`() = runTest {
val dispatcher = UnconfinedTestDispatcher(testScheduler)
val authenticator = mock<Authenticator> {
everySuspend { authenticate("foo", "bar") } returns true
}
val navigator = mock<Navigator>(MockMode.autoUnit)
viewModel = LoginViewModel(dispatcher, authenticator, navigator)
viewModel.onUsernameChanged("foo")
viewModel.onPasswordChanged("bar")
viewModel.onSubmit()
assert(!viewModel.submitEnabled.value)
assert(!viewModel.loading.value)
verifySuspend(VerifyMode.exhaustiveOrder) {
authenticator.authenticate("foo", "bar")
navigator.goTo("Home")
}
}jsTest)The LoginView uses the DOM DSL to build HTML and binds ViewModel state flows directly to DOM properties:
fun loginView(viewModel: LoginViewModel) = node { form("app-login") {
it.onsubmit = { event -> event.preventDefault() }
div("field label border round prefix") {
icon("mail")
input("large border", name = "username", type = "text") { input ->
input.oninput = {
viewModel.onUsernameChanged(input.value)
}
}
label { +"Username" }
}
// ...
nav("no-space") {
button("large", type = "submit") { button ->
button.ariaLabel = "Submit"
viewModel.submitEnabled.onEach { enabled ->
button.disabled = !enabled
}.launchIn(viewModel.scope)
button.onclick = { viewModel.onSubmit() }
+"Submit"
}
}
}}The view function returns a DOM node that can be appended to the document. ViewModel StateFlows are collected with onEach { ... }.launchIn(viewModel.scope) to reactively update DOM properties like disabled and hidden.
All the gradle dependencies are managed by the libs.versions.toml file in the gradle dir.
It is easy to check for the latest version by running:
./gradlew dependencyUpdates --no-parallel