Compare commits

...

15 Commits

Author SHA1 Message Date
88cf5b8533 Bump version to 1.2.10, simplify attribute and event handling in ElementExtensions, and remove deprecated logic in HtmlBuilder.
Some checks failed
Gradle CI / build (push) Has been cancelled
2025-10-18 16:24:52 +02:00
d3fef98203 v.1.2.9 - Fix memory leak
Some checks failed
Gradle CI / build (push) Has been cancelled
2025-09-19 19:19:07 +02:00
191b23ed51 Bump version to 1.2.8, add TestStyleUpdate for verifying style attribute updates and removals, and simplify style attribute handling in HtmlBuilder.
Some checks failed
Gradle CI / build (push) Has been cancelled
2025-06-12 19:22:31 +02:00
66b4633e6b Update README to reflect version 1.2.7 release on Maven Central
Some checks failed
Gradle CI / build (push) Has been cancelled
2025-05-30 12:26:37 +02:00
177d96975a Bump version to 1.2.7, add TestSvg for testing SVG rendering, and remove redundant class attribute handling in ElementExtensions.
Some checks failed
Gradle CI / build (push) Has been cancelled
2025-05-30 12:22:21 +02:00
e97b6966ba Release v. 1.2.6
Some checks failed
Gradle CI / build (push) Has been cancelled
2025-05-03 10:13:18 +02:00
283c19defb Fix and test class attribute handling in ElementExtensions.
Some checks failed
Gradle CI / build (push) Has been cancelled
Re-enabled previously commented-out logic for managing the "class" attribute in ElementExtensions to ensure proper updates and removals. Added comprehensive unit tests in `TestClassUpdate` to verify behavior, including class addition, removal, and name changes. Bumped the project version to 1.2.6.
2025-04-20 17:50:15 +02:00
0d2f2146e9 Add guidelines and tests for Komponent development
Some checks failed
Gradle CI / build (push) Has been cancelled
Introduce detailed development guidelines for the Kotlin Komponent (Komp) library, covering build instructions, testing, optimization, and code style. Additionally, add a new test case for verifying dynamic row insertion in a table component.
2025-04-18 14:52:48 +02:00
497ca14c27 Github action
Some checks failed
Gradle CI / build (push) Has been cancelled
2025-04-06 15:35:26 +02:00
a5e7963412 Migrate to com.vanniktech.maven.publish publish plugin, update gradle, publish to maven central.
Some checks are pending
Gradle CI / build (push) Waiting to run
2025-04-06 15:22:12 +02:00
6f37c879c2 Migrate to com.vanniktech.maven.publish publish plugin, update gradle, publish to maven central.
Some checks are pending
Gradle CI / build (push) Waiting to run
2025-04-06 15:13:49 +02:00
ab5689133f Migrate to com.vanniktech.maven.publish publish plugin, update gradle, publish to maven central. 2025-04-06 15:13:38 +02:00
9e08601bb7 Update readme.md 2025-04-06 15:13:16 +02:00
c68a024552 Upgrade Kotlin version and simplify Maven signing tasks.
Some checks failed
Gradle CI / build (push) Has been cancelled
Updated Kotlin multiplatform plugin to version 2.1.10. Removed redundant Maven publication task dependencies and streamlined the signing process for better maintainability.
2025-03-17 18:20:16 +00:00
46bb07d567 Version 1.2.5-SNAPSHOT
Some checks failed
Gradle CI / build (push) Has been cancelled
2024-10-23 15:36:53 +02:00
22 changed files with 605 additions and 1321 deletions

View File

@@ -9,9 +9,9 @@ jobs:
steps:
- uses: actions/checkout@v1
- name: Set up JDK 1.8
- name: Set up JDK 11
uses: actions/setup-java@v1
with:
java-version: 1.8
java-version: 11
- name: Build with Gradle
run: ./gradlew build

144
.junie/guidelines.md Normal file
View File

@@ -0,0 +1,144 @@
# Kotlin Komponent (Komp) Development Guidelines
This document provides specific information for developing with the Kotlin Komponent (Komp) library, a component-based UI library for Kotlin/JS.
## Build/Configuration Instructions
### Prerequisites
- Kotlin 2.1.10 or higher
- Gradle 7.0 or higher
### Building the Project
The project uses Kotlin Multiplatform with a focus on JavaScript (and potentially WebAssembly in the future).
```bash
# Build the project
./gradlew build
# Build only JS target
./gradlew jsJar
# Publish to Maven Local for local development
./gradlew publishToMavenLocal
```
### Configuration
The project uses the following Gradle plugins:
- Kotlin Multiplatform
- Maven Publish
- Dokka for documentation
Key configuration files:
- `build.gradle.kts` - Main build configuration
- `gradle.properties` - Contains publishing credentials and signing configuration
- `settings.gradle.kts` - Project settings and repository configuration
## Testing Information
### Running Tests
Tests are written using the Kotlin Test library and run with Karma using Chrome Headless.
```bash
# Run all tests
./gradlew jsTest
# Run browser tests
./gradlew jsBrowserTest
```
### Test Structure
Tests are located in the `src/jsTest` directory. The project uses the standard Kotlin Test library with annotations:
```kotlin
@Test
fun testSomething() {
// Test code here
}
```
### Writing New Tests
When writing tests for Komponents:
1. Create a test class in the `src/jsTest/kotlin/nl/astraeus/komp` directory
2. Use the `@Test` annotation for test methods
3. For UI component tests:
- Create a test DOM element using `document.createElement("div")`
- Create your Komponent instance
- Render it using `Komponent.create(element, komponent)`
- Modify state and call `requestImmediateUpdate()` to test updates
- Verify the DOM structure using assertions
### Example Test
Here's a simple test example:
```kotlin
@Test
fun testSimpleComponent() {
// Create a test component
val component = SimpleKomponent()
val div = document.createElement("div") as HTMLDivElement
// Render it
Komponent.create(div, component)
// Verify initial state
assertEquals("Hello", div.querySelector("div")?.textContent)
// Update state and re-render
component.hello = false
component.requestImmediateUpdate()
// Verify updated state
assertEquals("Good bye", div.querySelector("span")?.textContent)
}
```
## Additional Development Information
### Project Structure
- `src/commonMain` - Common Kotlin code
- `src/jsMain` - JavaScript-specific implementation
- `src/wasmJsMain` - WebAssembly JavaScript implementation (experimental)
- `src/jsTest` - JavaScript tests
### Key Components
1. **Komponent** - Base class for all UI components
- Override `HtmlBuilder.render()` to define the component's UI
- Use `requestUpdate()` for scheduled updates
- Use `requestImmediateUpdate()` for immediate updates
- Override `generateMemoizeHash()` to optimize re-renders
2. **HtmlBuilder** - Handles DOM creation and updates
- Uses a virtual DOM-like approach to update only what changed
- Supports including child components with `include()`
- Handles attribute and event binding
3. **ElementExtensions** - Utility functions for DOM manipulation
### Optimization Features
- **Memoization**: Components can implement `generateMemoizeHash()` to avoid unnecessary re-renders
- **Batched Updates**: Multiple `requestUpdate()` calls are batched for performance
- **Efficient DOM Updates**: Only changed elements are updated in the DOM
### Error Handling
The library provides detailed error information when rendering fails:
- Set custom error handlers with `Komponent.setErrorHandler()`
- Enable debug logging with `Komponent.logRenderEvent = true`
- Use `debug {}` blocks in render functions for additional validation
### Code Style
- Follow Kotlin's official code style (`kotlin.code.style=official`)
- Use functional programming patterns where appropriate
- Prefer immutable state when possible
- Use descriptive names for components and methods

View File

@@ -1,58 +1,74 @@
@file:OptIn(ExperimentalWasmDsl::class)
import com.vanniktech.maven.publish.SonatypeHost
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
plugins {
kotlin("multiplatform") version "2.0.20"
`maven-publish`
kotlin("multiplatform") version "2.1.10"
signing
id("org.jetbrains.dokka") version "1.5.31"
id("org.jetbrains.dokka") version "2.0.0"
id("com.vanniktech.maven.publish") version "0.31.0"
}
group = "nl.astraeus"
version = "1.2.4"
version = "1.2.10"
repositories {
mavenCentral()
maven {
name = "Sonatype Releases"
url = uri("https://central.sonatype.com/api/v1/publisher/deployments/download/")
}
}
/*
tasks.withType<Test>(Test::class.java) {
useJUnitPlatform()
}
*/
kotlin {
js {
browser {
/* testTask {
testTask {
useKarma {
useChromiumHeadless()
useChromeHeadless()
}
}*/
}
}
wasmJs {
//moduleName = project.name
browser()
mavenPublication {
groupId = group as String
pom { name = "${project.name}-wasm-js" }
}
}
/*
@OptIn(ExperimentalKotlinGradlePluginApi::class)
applyDefaultHierarchyTemplate {
common {
group("jsCommon") {
withJs()
// TODO: switch to `withWasmJs()` after upgrade to Kotlin 2.0
withWasm()
}
}
}
*/
/* wasmJs {
//moduleName = project.name
browser()
mavenPublication {
groupId = group as String
pom { name = "${project.name}-wasm-js" }
}
}*/
/*
@OptIn(ExperimentalKotlinGradlePluginApi::class)
applyDefaultHierarchyTemplate {
common {
group("jsCommon") {
withJs()
// TODO: switch to `withWasmJs()` after upgrade to Kotlin 2.0
withWasm()
}
}
}
*/
sourceSets {
val commonMain by getting {
dependencies {
api("org.jetbrains.kotlinx:kotlinx-html:0.11.0")
api("org.jetbrains.kotlinx:kotlinx-html:0.12.0")
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
}
}
val jsMain by getting
@@ -61,161 +77,63 @@ kotlin {
implementation(kotlin("test"))
}
}
val wasmJsMain by getting
//val wasmJsMain by getting
}
}
extra["PUBLISH_GROUP_ID"] = group
extra["PUBLISH_VERSION"] = version
extra["PUBLISH_ARTIFACT_ID"] = name
// Stub secrets to let the project sync and build without the publication values set up
val signingKeyId: String? by project
val signingPassword: String? by project
val signingSecretKeyRingFile: String? by project
val ossrhUsername: String? by project
val ossrhPassword: String? by project
extra["signing.keyId"] = signingKeyId
extra["signing.password"] = signingPassword
extra["signing.secretKeyRingFile"] = signingSecretKeyRingFile
extra["ossrhUsername"] = ossrhUsername
extra["ossrhPassword"] = ossrhPassword
val javadocJar by tasks.registering(Jar::class) {
archiveClassifier.set("javadoc")
}
publishing {
repositories {
mavenLocal()
maven {
name = "releases"
// change to point to your repo, e.g. http://my.org/repo
setUrl("https://reposilite.astraeus.nl/releases")
credentials {
val reposiliteUsername: String? by project
val reposilitePassword: String? by project
username = reposiliteUsername
password = reposilitePassword
}
}
maven {
name = "snapshots"
// change to point to your repo, e.g. http://my.org/repo
setUrl("https://reposilite.astraeus.nl/snapshots")
credentials {
val reposiliteUsername: String? by project
val reposilitePassword: String? by project
username = reposiliteUsername
password = reposilitePassword
}
}
maven {
name = "sonatype"
setUrl("https://s01.oss.sonatype.org/service/local/staging/deploy/maven2")
credentials {
username = ossrhUsername
password = ossrhPassword
}
}
maven {
name = "gitea"
setUrl("https://gitea.astraeus.nl/api/packages/rnentjes/maven")
credentials() {
val giteaUsername: kotlin.String? by project
val giteaPassword: kotlin.String? by project
credentials {
val giteaUsername: String? by project
val giteaPassword: String? by project
username = giteaUsername
password = giteaPassword
}
}
}
}
// Configure all publications
publications.withType<MavenPublication> {
// Stub javadoc.jar artifact
artifact(javadocJar.get())
// Provide artifacts information requited by Maven Central
pom {
name.set("kotlin-komponent")
description.set("Kotlin komponent")
url.set("https://github.com/rnentjes/komponent")
licenses {
license {
name.set("MIT")
url.set("https://opensource.org/licenses/MIT")
}
}
developers {
developer {
id.set("rnentjes")
name.set("Rien Nentjes")
email.set("info@nentjes.com")
}
}
scm {
url.set("https://github.com/rnentjes/komponent")
}
}
}
tasks.withType<AbstractPublishToMaven> {
dependsOn(tasks.withType<Sign>())
}
signing {
sign(publishing.publications)
}
tasks.named<Task>("publishJsPublicationToReleasesRepository") {
dependsOn(tasks.named<Task>("signKotlinMultiplatformPublication"))
}
mavenPublishing {
publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL)
tasks.named<Task>("publishKotlinMultiplatformPublicationToMavenLocalRepository") {
dependsOn(tasks.named<Task>("signJsPublication"))
dependsOn(tasks.named<Task>("signWasmJsPublication"))
}
signAllPublications()
tasks.named<Task>("publishKotlinMultiplatformPublicationToReleasesRepository") {
dependsOn(tasks.named<Task>("signJsPublication"))
dependsOn(tasks.named<Task>("signWasmJsPublication"))
}
coordinates(group.toString(), name, version.toString())
tasks.named<Task>("publishKotlinMultiplatformPublicationToSonatypeRepository") {
dependsOn(tasks.named<Task>("signJsPublication"))
dependsOn(tasks.named<Task>("signWasmJsPublication"))
}
tasks.named<Task>("publishJsPublicationToMavenLocalRepository") {
dependsOn(tasks.named<Task>("signWasmJsPublication"))
}
tasks.named<Task>("publishWasmJsPublicationToMavenLocalRepository") {
dependsOn(tasks.named<Task>("signJsPublication"))
}
tasks.named<Task>("publishWasmJsPublicationToMavenLocal") {
dependsOn(tasks.named<Task>("signJsPublication"))
}
tasks.named<Task>("publishJsPublicationToGiteaRepository") {
dependsOn(tasks.named<Task>("signKotlinMultiplatformPublication"))
dependsOn(tasks.named<Task>("signJsPublication"))
dependsOn(tasks.named<Task>("signWasmJsPublication"))
}
tasks.named<Task>("publishKotlinMultiplatformPublicationToGiteaRepository") {
dependsOn(tasks.named<Task>("signKotlinMultiplatformPublication"))
dependsOn(tasks.named<Task>("signJsPublication"))
dependsOn(tasks.named<Task>("signWasmJsPublication"))
}
tasks.named<Task>("publishWasmJsPublicationToGiteaRepository") {
dependsOn(tasks.named<Task>("signKotlinMultiplatformPublication"))
dependsOn(tasks.named<Task>("signJsPublication"))
dependsOn(tasks.named<Task>("signWasmJsPublication"))
pom {
name = "kotlin-komponent"
description = "Kotlin komponent"
inceptionYear = "2017"
url = "https://github.com/rnentjes/komponent"
licenses {
license {
name = "MIT"
url = "https://opensource.org/licenses/MIT"
}
}
developers {
developer {
id = "rnentjes"
name = "Rien Nentjes"
email = "info@nentjes.com"
}
}
scm {
url = "https://github.com/rnentjes/komponent"
}
}
}

View File

@@ -1,5 +1,5 @@
#Wed Mar 04 13:29:12 CET 2020
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-all.zip
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStorePath=wrapper/dists

View File

@@ -8,6 +8,6 @@ See the komp-todo repository for a basic example here: [komp-todo](https://githu
For a more complete example take a look at the simple-password-manager repository: [simple-password-manager](https://github.com/rnentjes/simple-password-manager)
Available on maven central: "nl.astraeus:kotlin-komponent-js:1.0.0"
Available on maven central: "nl.astraeus:kotlin-komponent-js:1.2.7"
Some getting started documentation can be found [here](docs/getting-started.md)
Some getting started documentation can be found [here](docs/getting-started.md)

View File

@@ -1,2 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "kotlin-komponent"

View File

@@ -1,9 +1,8 @@
package nl.astraeus.komp
import org.w3c.dom.events.Event
import org.w3c.dom.Element
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.events.EventListener
import org.w3c.dom.events.Event
import org.w3c.dom.get
private fun Int.asSpaces(): String {
@@ -41,14 +40,6 @@ fun Element.printTree(indent: Int = 0): String {
}
result.append(") {")
result.append("\n")
for ((name, event) in getKompEvents()) {
result.append(indent.asSpaces())
result.append("on")
result.append(name)
result.append(" -> ")
result.append(event)
result.append("\n")
}
for (index in 0 until childNodes.length) {
childNodes[index]?.let {
if (it is Element) {
@@ -66,59 +57,52 @@ fun Element.printTree(indent: Int = 0): String {
return result.toString()
}
internal fun Element.setKompAttribute(attributeName: String, value: String?) {
//val attributeName = name.lowercase()
if (value == null || value.isBlank()) {
if (this is HTMLInputElement) {
when (attributeName) {
"checked" -> {
checked = false
}
/*
"class" -> {
className = ""
}
*/
"value" -> {
this.value = ""
}
else -> {
removeAttribute(attributeName)
}
internal fun Element.setKompAttribute(attributeName: String, value: String) {
if (this is HTMLInputElement) {
when (attributeName) {
"checked" -> {
checked = "checked" == value
}
} else {
removeAttribute(attributeName)
}
} else {
if (this is HTMLInputElement) {
when (attributeName) {
"checked" -> {
checked = "checked" == value
}
/*
"class" -> {
className = value
}
*/
"value" -> {
this.value = value
}
else -> {
setAttribute(attributeName, value)
}
"class" -> {
className = value
}
"value" -> {
this.value = value
}
else -> {
setAttribute(attributeName, value)
}
} else if (this.getAttribute(attributeName) != value) {
setAttribute(attributeName, value)
}
} else if (this.getAttribute(attributeName) != value) {
setAttribute(attributeName, value)
}
}
internal fun Element.clearKompEvents() {
val events = getKompEvents()
for ((name, event) in getKompEvents()) {
removeEventListener(name, event)
internal fun Element.clearKompAttribute(attributeName: String) {
if (this is HTMLInputElement) {
when (attributeName) {
"checked" -> {
checked = false
}
"class" -> {
className = ""
}
"value" -> {
this.value = ""
}
else -> {
removeAttribute(attributeName)
}
}
} else {
removeAttribute(attributeName)
}
events.clear()
}
internal fun Element.setKompEvent(name: String, event: (Event) -> Unit) {
@@ -128,17 +112,9 @@ internal fun Element.setKompEvent(name: String, event: (Event) -> Unit) {
name
}
getKompEvents()[eventName] = event
this.addEventListener(eventName, event)
}
private val kompEvents = mutableMapOf<Element, MutableMap<String, (Event) -> Unit>>()
internal fun Element.getKompEvents(): MutableMap<String, (Event) -> Unit> {
return kompEvents.getOrPut(this) { mutableMapOf() }
}
internal fun Element.findElementIndex(): Int {
val childNodes = parentElement?.children
if (childNodes != null) {

View File

@@ -6,7 +6,6 @@ import org.w3c.dom.get
data class ElementIndex(
val parent: Node,
var childIndex: Int,
var setAttr: MutableSet<String> = mutableSetOf()
) {
override fun toString(): String {
return "${parent.nodeName}[$childIndex]"
@@ -39,7 +38,6 @@ fun ArrayList<ElementIndex>.currentPosition(): ElementIndex? {
fun ArrayList<ElementIndex>.nextElement() {
this.lastOrNull()?.let {
it.setAttr.clear()
it.childIndex++
}
}

View File

@@ -26,7 +26,7 @@ interface HtmlConsumer : TagConsumer<Element> {
fun FlowOrMetaDataOrPhrasingContent.currentElement(): Element =
currentElement ?: error("No current element defined!")
private fun Node.asElement() = this as? HTMLElement
private fun Node?.asElement() = this as? HTMLElement
class HtmlBuilder(
private val komponent: Komponent?,
@@ -57,12 +57,6 @@ class HtmlBuilder(
}
} else {
// current element should become parent
/*
val ce = komponent.element
if (ce != null) {
append(ce as Element)
}
*/
komponent.create(
currentPosition.last().parent as Element,
currentPosition.last().childIndex
@@ -100,39 +94,26 @@ class HtmlBuilder(
"onTagStart, [${tag.tagName}, ${tag.namespace ?: ""}], currentPosition: $currentPosition"
}
currentNode = currentPosition.currentElement()
currentNode = if (tag.namespace != null) {
document.createElementNS(tag.namespace, tag.tagName)
} else {
document.createElement(tag.tagName)
}
if (currentNode == null) {
logReplace { "onTagStart, currentNode1: $currentNode" }
currentNode = if (tag.namespace != null) {
document.createElementNS(tag.namespace, tag.tagName)
} else {
document.createElement(tag.tagName)
}
logReplace { "onTagStart, currentElement1.1: $currentNode" }
currentPosition.currentParent().appendChild(currentNode!!)
} else if (
!currentNode?.asElement()?.tagName.equals(tag.tagName, true) ||
(
tag.namespace != null &&
!currentNode?.asElement()?.namespaceURI.equals(tag.namespace, true)
)
) {
logReplace {
"onTagStart, currentElement, namespace: ${currentNode?.asElement()?.namespaceURI} -> ${tag.namespace}"
}
logReplace {
"onTagStart, currentElement, replace: ${currentNode?.asElement()?.tagName} -> ${tag.tagName}"
}
} else {
logReplace {
"onTagStart, currentElement, namespace: ${currentNode?.asElement()?.namespaceURI} -> ${tag.namespace}"
}
logReplace {
"onTagStart, currentElement, replace: ${currentNode?.asElement()?.tagName} -> ${tag.tagName}"
}
currentNode = if (tag.namespace != null) {
document.createElementNS(tag.namespace, tag.tagName)
} else {
document.createElement(tag.tagName)
}
currentPosition.replace(currentNode!!)
currentPosition.replace(currentNode!!)
}
currentElement = currentNode as? Element ?: currentElement
@@ -144,15 +125,8 @@ class HtmlBuilder(
firstTag = false
}
currentElement?.clearKompEvents()
// if currentElement = checkbox make sure it's cleared
(currentElement as? HTMLInputElement)?.checked = false
currentPosition.lastOrNull()?.setAttr?.clear()
for (entry in tag.attributesEntries) {
currentElement!!.setKompAttribute(entry.key, entry.value)
currentPosition.lastOrNull()?.setAttr?.add(entry.key)
currentElement?.setKompAttribute(entry.key, entry.value)
}
}
@@ -181,11 +155,10 @@ class HtmlBuilder(
checkTag("onTagAttributeChange", tag)
}
currentElement?.setKompAttribute(attribute, value)
if (value == null || value.isEmpty()) {
currentPosition.currentPosition()?.setAttr?.remove(attribute)
currentElement?.clearKompAttribute(attribute)
} else {
currentPosition.currentPosition()?.setAttr?.add(attribute)
currentElement?.setKompAttribute(attribute, value)
}
}
@@ -222,28 +195,6 @@ class HtmlBuilder(
checkTag("onTagEnd", tag)
}
if (currentElement != null) {
val setAttrs: Set<String> = currentPosition.currentPosition()?.setAttr ?: setOf()
// remove attributes that where not set
val element = currentElement
if (element?.hasAttributes() == true) {
for (index in 0 until element.attributes.length) {
val attribute = element.attributes[index]
if (attribute?.name != null) {
val attr = attribute.name
if (
!setAttrs.contains(attr) &&
attr != "style"
) {
element.setKompAttribute(attr, null)
}
}
}
}
}
currentPosition.pop()
currentNode = currentPosition.currentElement()

View File

@@ -191,6 +191,7 @@ abstract class Komponent {
private var scheduledForUpdate = mutableSetOf<Komponent>()
private var interceptor: (Komponent, () -> Unit) -> Unit = { _, block -> block() }
var logUpdateEvent = false
var logRenderEvent = false
var logReplaceEvent = false
var enableAssertions = false
@@ -244,6 +245,9 @@ abstract class Komponent {
val memoizeHash = next.generateMemoizeHash()
if (next.memoizeChanged()) {
if (logUpdateEvent) {
console.log("Rendering", next)
}
next.onBeforeUpdate()
next.renderUpdate()
next.updateMemoizeHash()

View File

@@ -0,0 +1,73 @@
package nl.astraeus.komp
import kotlinx.browser.document
import kotlinx.html.div
import kotlinx.html.classes
import org.w3c.dom.HTMLDivElement
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Test class for verifying class attribute updates and removals
*/
class ClassKomponent : Komponent() {
var includeClass = true
var className = "test-class"
override fun HtmlBuilder.render() {
div {
if (includeClass) {
classes = setOf(className)
}
+"Content"
}
}
}
class TestClassUpdate {
@Test
fun testClassRemoval() {
// Create a test component
val classComponent = ClassKomponent()
val div = document.createElement("div") as HTMLDivElement
// Render it
Komponent.create(div, classComponent)
// Verify initial state - should have the class
var contentDiv = div.querySelector("div")
println("[DEBUG_LOG] Initial DOM: ${div.printTree()}")
assertTrue(contentDiv?.classList?.contains("test-class") ?: false, "Div should have the class initially")
// Update to remove the class
classComponent.includeClass = false
classComponent.requestImmediateUpdate()
// Verify the class was removed
contentDiv = div.querySelector("div")
println("[DEBUG_LOG] After class removal: ${div.printTree()}")
assertFalse(contentDiv?.classList?.contains("test-class") ?: true, "Class should be removed after update")
// Add the class back
classComponent.includeClass = true
classComponent.requestImmediateUpdate()
// Verify the class was added back
contentDiv = div.querySelector("div")
println("[DEBUG_LOG] After class added back: ${div.printTree()}")
assertTrue(contentDiv?.classList?.contains("test-class") ?: false, "Class should be added back")
// Change the class name
classComponent.className = "new-class"
classComponent.requestImmediateUpdate()
// Verify the class was changed
contentDiv = div.querySelector("div")
println("[DEBUG_LOG] After class name change: ${div.printTree()}")
assertFalse(contentDiv?.classList?.contains("test-class") ?: true, "Old class should be removed")
assertTrue(contentDiv?.classList?.contains("new-class") ?: false, "New class should be added")
}
}

View File

@@ -0,0 +1,100 @@
package nl.astraeus.komp
import kotlinx.browser.document
import kotlinx.html.div
import kotlinx.html.table
import kotlinx.html.tbody
import kotlinx.html.td
import kotlinx.html.tr
import org.w3c.dom.HTMLDivElement
import org.w3c.dom.HTMLTableElement
import org.w3c.dom.HTMLTableRowElement
import org.w3c.dom.HTMLTableCellElement
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
/**
* A Komponent that renders a table with rows
*/
class TableKomponent : Komponent() {
val rows = mutableListOf<RowKomponent>()
fun addRow(text: String) {
rows.add(RowKomponent(text))
requestImmediateUpdate()
}
override fun HtmlBuilder.render() {
div {
table {
tbody {
for (row in rows) {
include(row)
}
}
}
}
}
}
/**
* A Komponent that represents a single row in a table
*/
class RowKomponent(val text: String) : Komponent() {
override fun generateMemoizeHash(): Int = text.hashCode()
override fun HtmlBuilder.render() {
tr {
td {
+text
}
}
}
}
/**
* Test class for inserting rows in the DOM with a Komponent
*/
class TestInsert {
@Test
fun testInsertRow() {
// Create a test component
val tableComponent = TableKomponent()
val div = document.createElement("div") as HTMLDivElement
// Render it
Komponent.create(div, tableComponent)
// Verify initial state - should be an empty table
val table = div.querySelector("table")
assertNotNull(table, "Table should be rendered")
val initialRows = table.querySelectorAll("tr")
assertEquals(0, initialRows.length, "Table should initially have no rows")
// Add a row and verify it was inserted
tableComponent.addRow("First Row")
// Verify the row was added
val rowsAfterFirstInsert = div.querySelector("table")?.querySelectorAll("tr")
assertEquals(1, rowsAfterFirstInsert?.length, "Table should have one row after insertion")
val firstRowCell = div.querySelector("table")?.querySelector("tr td")
assertNotNull(firstRowCell, "First row cell should exist")
assertEquals("First Row", firstRowCell.textContent, "Row content should match")
// Add another row and verify it was inserted
tableComponent.addRow("Second Row")
// Verify both rows are present
val rowsAfterSecondInsert = div.querySelector("table")?.querySelectorAll("tr")
assertEquals(2, rowsAfterSecondInsert?.length, "Table should have two rows after second insertion")
val allCells = div.querySelector("table")?.querySelectorAll("tr td")
assertEquals(2, allCells?.length, "Table should have two cells")
assertEquals("First Row", allCells?.item(0)?.textContent, "First row content should match")
assertEquals("Second Row", allCells?.item(1)?.textContent, "Second row content should match")
// Print the DOM tree for debugging
println("Table DOM: ${div.printTree()}")
}
}

View File

@@ -0,0 +1,79 @@
package nl.astraeus.komp
import kotlinx.browser.document
import kotlinx.html.div
import kotlinx.html.style
import org.w3c.dom.HTMLDivElement
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* Test class for verifying style attribute updates and removals
*/
class StyleKomponent : Komponent() {
var includeStyle = true
var styleValue = "color: red;"
override fun HtmlBuilder.render() {
div {
if (includeStyle) {
style = styleValue
}
+"Content"
}
}
}
class TestStyleUpdate {
@Test
fun testStyleRemoval() {
// Create a test component
val styleComponent = StyleKomponent()
val div = document.createElement("div") as HTMLDivElement
// Render it
Komponent.create(div, styleComponent)
// Verify initial state - should have the style
var contentDiv = div.querySelector("div")
println("[DEBUG_LOG] Initial DOM: ${div.printTree()}")
assertEquals(
"color: red;",
contentDiv?.getAttribute("style"),
"Div should have the style initially"
)
// Update to remove the style
styleComponent.includeStyle = false
styleComponent.requestImmediateUpdate()
// Verify the style was removed
contentDiv = div.querySelector("div")
println("[DEBUG_LOG] After style removal: ${div.printTree()}")
assertNull(contentDiv?.getAttribute("style"), "Style should be removed after update")
// Add the style back
styleComponent.includeStyle = true
styleComponent.requestImmediateUpdate()
// Verify the style was added back
contentDiv = div.querySelector("div")
println("[DEBUG_LOG] After style added back: ${div.printTree()}")
assertEquals("color: red;", contentDiv?.getAttribute("style"), "Style should be added back")
// Change the style value
styleComponent.styleValue = "color: blue;"
styleComponent.requestImmediateUpdate()
// Verify the style was changed
contentDiv = div.querySelector("div")
println("[DEBUG_LOG] After style value change: ${div.printTree()}")
assertEquals(
"color: blue;",
contentDiv?.getAttribute("style"),
"Style should be updated to new value"
)
}
}

View File

@@ -0,0 +1,50 @@
package nl.astraeus.komp
import kotlinx.browser.document
import kotlinx.html.InputType
import kotlinx.html.classes
import kotlinx.html.div
import kotlinx.html.i
import kotlinx.html.id
import kotlinx.html.input
import kotlinx.html.js.onClickFunction
import kotlinx.html.p
import kotlinx.html.span
import kotlinx.html.svg
import kotlinx.html.table
import kotlinx.html.td
import kotlinx.html.tr
import kotlinx.html.unsafe
import org.w3c.dom.Element
import org.w3c.dom.HTMLDivElement
import kotlin.test.Test
class TestSvgKomponent : Komponent() {
override fun HtmlBuilder.render() {
div {
+"Test"
svg("my-class") {
classes += "added-class"
unsafe {
+"""arc(1,2)"""
}
}
}
}
}
class TestSvg {
@Test
fun testUpdateWithEmpty() {
val div = document.createElement("div") as HTMLDivElement
val rk = TestSvgKomponent()
Komponent.logRenderEvent = true
Komponent.create(div, rk)
println("SvgKomponent: ${div.printTree()}")
}
}

View File

@@ -1,7 +1,6 @@
package nl.astraeus.komp
import kotlinx.browser.document
import kotlinx.html.DIV
import kotlinx.html.InputType
import kotlinx.html.classes
import kotlinx.html.div

View File

@@ -1,154 +0,0 @@
package nl.astraeus.komp
import org.w3c.dom.AddEventListenerOptions
import org.w3c.dom.events.Event
import org.w3c.dom.Element
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.events.EventListener
import org.w3c.dom.get
private fun Int.asSpaces(): String {
val result = StringBuilder()
repeat(this) {
result.append(" ")
}
return result.toString()
}
fun Element.printTree(indent: Int = 0): String {
val result = StringBuilder()
result.append(indent.asSpaces())
result.append(tagName)
if (this.namespaceURI != "http://www.w3.org/1999/xhtml") {
result.append(" [")
result.append(namespaceURI)
result.append("]")
}
result.append(" (")
var first = true
if (hasAttributes()) {
for (index in 0 until attributes.length) {
if (!first) {
result.append(", ")
} else {
first = false
}
result.append(attributes[index]?.localName)
result.append("=")
result.append(attributes[index]?.value)
}
}
result.append(") {")
result.append("\n")
for ((name, event) in getKompEvents()) {
result.append(indent.asSpaces())
result.append("on")
result.append(name)
result.append(" -> ")
result.append(event)
result.append("\n")
}
for (index in 0 until childNodes.length) {
childNodes[index]?.let {
if (it is Element) {
result.append(it.printTree(indent + 2))
} else {
result.append((indent + 2).asSpaces())
result.append(it.textContent)
result.append("\n")
}
}
}
result.append(indent.asSpaces())
result.append("}\n")
return result.toString()
}
internal fun Element.setKompAttribute(attributeName: String, value: String?) {
//val attributeName = name.lowercase()
if (value == null || value.isBlank()) {
if (this is HTMLInputElement) {
when (attributeName) {
"checked" -> {
checked = false
}
/*
"class" -> {
className = ""
}
*/
"value" -> {
this.value = ""
}
else -> {
removeAttribute(attributeName)
}
}
} else {
removeAttribute(attributeName)
}
} else {
if (this is HTMLInputElement) {
when (attributeName) {
"checked" -> {
checked = "checked" == value
}
/*
"class" -> {
className = value
}
*/
"value" -> {
this.value = value
}
else -> {
setAttribute(attributeName, value)
}
}
} else if (this.getAttribute(attributeName) != value) {
setAttribute(attributeName, value)
}
}
}
internal fun Element.clearKompEvents() {
val events = getKompEvents()
for ((name, event) in getKompEvents()) {
removeEventListener(name, event)
}
events.clear()
}
internal fun Element.setKompEvent(name: String, event: (Event) -> Unit) {
val eventName: String = if (name.startsWith("on")) {
name.substring(2)
} else {
name
}
getKompEvents()[eventName] = event
this.addEventListener(eventName, event) // AddEventListenerOptions(true))
}
private val kompEvents = mutableMapOf<Element, MutableMap<String, (Event) -> Unit>>()
internal fun Element.getKompEvents(): MutableMap<String, (Event) -> Unit> {
return kompEvents.getOrPut(this) { mutableMapOf() }
}
internal fun Element.findElementIndex(): Int {
val childNodes = parentElement?.children
if (childNodes != null) {
for (index in 0 until childNodes.length) {
if (childNodes[index] == this) {
return index
}
}
}
return 0
}

View File

@@ -1,64 +0,0 @@
package nl.astraeus.komp
import org.w3c.dom.Node
import org.w3c.dom.get
data class ElementIndex(
val parent: Node,
var childIndex: Int,
var setAttr: MutableSet<String> = mutableSetOf()
) {
override fun toString(): String {
return "${parent.nodeName}[$childIndex]"
}
}
fun ArrayList<ElementIndex>.currentParent(): Node {
this.lastOrNull()?.let {
return it.parent
}
throw IllegalStateException("currentParent should never be null!")
}
fun ArrayList<ElementIndex>.currentElement(): Node? {
this.lastOrNull()?.let {
return it.parent.childNodes[it.childIndex]
}
return null
}
fun ArrayList<ElementIndex>.currentPosition(): ElementIndex? {
return if (this.size < 2) {
null
} else {
this[this.size - 2]
}
}
fun ArrayList<ElementIndex>.nextElement() {
this.lastOrNull()?.let {
it.setAttr.clear()
it.childIndex++
}
}
fun ArrayList<ElementIndex>.pop() {
this.removeLast()
}
fun ArrayList<ElementIndex>.push(element: Node) {
this.add(ElementIndex(element, 0))
}
fun ArrayList<ElementIndex>.replace(new: Node) {
if (this.currentElement() != null) {
this.currentElement()?.parentElement?.replaceChild(
new,
this.currentElement()!!
)
} else {
this.last().parent.appendChild(new)
}
}

View File

@@ -1,405 +0,0 @@
package nl.astraeus.komp
import kotlinx.browser.document
import kotlinx.html.DefaultUnsafe
import kotlinx.html.Entities
import kotlinx.html.FlowOrMetaDataOrPhrasingContent
import kotlinx.html.Tag
import kotlinx.html.TagConsumer
import kotlinx.html.Unsafe
import org.w3c.dom.Element
import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.HTMLSpanElement
import org.w3c.dom.Node
import org.w3c.dom.asList
import org.w3c.dom.get
private var currentElement: Element? = null
interface HtmlConsumer : TagConsumer<Element> {
fun append(node: Element)
fun include(komponent: Komponent)
fun debug(block: HtmlConsumer.() -> Unit)
}
fun FlowOrMetaDataOrPhrasingContent.currentElement(): Element =
currentElement ?: error("No current element defined!")
private fun Node.asElement() = this as? HTMLElement
class HtmlBuilder(
private val komponent: Komponent?,
parent: Element,
childIndex: Int = 0
) : HtmlConsumer {
private var currentPosition = arrayListOf<ElementIndex>()
private var inDebug = false
private var exceptionThrown = false
private var currentNode: Node? = null
private var firstTag: Boolean = true
var root: Element? = null
init {
currentPosition.add(ElementIndex(parent, childIndex))
}
override fun include(komponent: Komponent) {
if (
komponent.element != null &&
!komponent.memoizeChanged()
) {
currentPosition.replace(komponent.element!!)
if (Komponent.logRenderEvent) {
println(
"Skipped include $komponent, memoize hasn't changed"
)
}
} else {
// current element should become parent
/*
val ce = komponent.element
if (ce != null) {
append(ce as Element)
}
*/
komponent.create(
currentPosition.last().parent as Element,
currentPosition.last().childIndex
)
}
currentPosition.nextElement()
}
override fun append(node: Element) {
currentPosition.replace(node)
currentPosition.nextElement()
}
override fun debug(block: HtmlConsumer.() -> Unit) {
val enableAssertions = Komponent.enableAssertions
Komponent.enableAssertions = true
inDebug = true
try {
block.invoke(this)
} finally {
inDebug = false
Komponent.enableAssertions = enableAssertions
}
}
private fun logReplace(msg: () -> String) {
if (Komponent.logReplaceEvent && inDebug) {
println(msg.invoke())
}
}
override fun onTagStart(tag: Tag) {
logReplace {
"onTagStart, [${tag.tagName}, ${tag.namespace ?: ""}], currentPosition: $currentPosition"
}
currentNode = currentPosition.currentElement()
if (currentNode == null) {
logReplace { "onTagStart, currentNode1: $currentNode" }
currentNode = if (tag.namespace != null) {
document.createElementNS(tag.namespace, tag.tagName)
} else {
document.createElement(tag.tagName)
}
logReplace { "onTagStart, currentElement1.1: $currentNode" }
currentPosition.currentParent().appendChild(currentNode!!)
} else if (
!currentNode?.asElement()?.tagName.equals(tag.tagName, true) ||
(
tag.namespace != null &&
!currentNode?.asElement()?.namespaceURI.equals(tag.namespace, true)
)
) {
logReplace {
"onTagStart, currentElement, namespace: ${currentNode?.asElement()?.namespaceURI} -> ${tag.namespace}"
}
logReplace {
"onTagStart, currentElement, replace: ${currentNode?.asElement()?.tagName} -> ${tag.tagName}"
}
currentNode = if (tag.namespace != null) {
document.createElementNS(tag.namespace, tag.tagName)
} else {
document.createElement(tag.tagName)
}
currentPosition.replace(currentNode!!)
}
currentElement = currentNode as? Element ?: currentElement
if (currentNode is Element) {
if (firstTag) {
logReplace { "Setting root: $currentNode" }
root = currentNode as Element
firstTag = false
}
currentElement?.clearKompEvents()
// if currentElement = checkbox make sure it's cleared
(currentElement as? HTMLInputElement)?.checked = false
currentPosition.lastOrNull()?.setAttr?.clear()
for (entry in tag.attributesEntries) {
currentElement!!.setKompAttribute(entry.key, entry.value)
currentPosition.lastOrNull()?.setAttr?.add(entry.key)
}
}
currentPosition.push(currentNode!!)
}
private fun checkTag(source: String, tag: Tag) {
check(currentElement != null) {
"No current tag ($source)"
}
check(currentElement?.tagName.equals(tag.tagName, ignoreCase = true)) {
"Wrong current tag ($source), got: ${tag.tagName} expected ${currentElement?.tagName}"
}
}
override fun onTagAttributeChange(
tag: Tag,
attribute: String,
value: String?
) {
logReplace { "onTagAttributeChange, ${tag.tagName} [$attribute, $value]" }
if (Komponent.enableAssertions) {
checkTag("onTagAttributeChange", tag)
}
currentElement?.setKompAttribute(attribute, value)
if (value == null || value.isEmpty()) {
currentPosition.currentPosition()?.setAttr?.remove(attribute)
} else {
currentPosition.currentPosition()?.setAttr?.add(attribute)
}
}
override fun onTagEvent(
tag: Tag,
event: String,
value: (kotlinx.html.org.w3c.dom.events.Event) -> Unit
) {
logReplace { "onTagEvent, ${tag.tagName} [$event, $value]" }
if (Komponent.enableAssertions) {
checkTag("onTagEvent", tag)
}
currentElement?.setKompEvent(event.lowercase(), value)
}
override fun onTagEnd(tag: Tag) {
logReplace {
"onTagEnd, [${tag.tagName}, ${tag.namespace}], currentPosition: $currentPosition"
}
if (exceptionThrown) {
return
}
while (currentPosition.currentElement() != null) {
currentPosition.currentElement()?.let {
it.parentElement?.removeChild(it)
}
}
if (Komponent.enableAssertions) {
checkTag("onTagEnd", tag)
}
if (currentElement != null) {
val setAttrs: Set<String> = currentPosition.currentPosition()?.setAttr ?: setOf()
// remove attributes that where not set
val element = currentElement
if (element?.hasAttributes() == true) {
for (index in 0 until element.attributes.length) {
val attribute = element.attributes[index]
if (attribute?.name != null) {
val attr = attribute.name
if (
!setAttrs.contains(attr) &&
attr != "style"
) {
element.setKompAttribute(attr, null)
}
}
}
}
}
currentPosition.pop()
currentNode = currentPosition.currentElement()
currentElement = currentNode as? Element ?: currentElement
currentPosition.nextElement()
currentElement = currentElement?.parentElement as? HTMLElement
//logReplace"onTagEnd, popped: $currentElement")
}
override fun onTagContent(content: CharSequence) {
//logReplace"onTagContent, [$content]")
check(currentElement != null) {
"No current DOM node"
}
//logReplace"Tag content: $content")
if (
currentElement?.nodeType != Node.TEXT_NODE ||
currentElement?.textContent != content.toString()
) {
currentElement?.textContent = content.toString()
}
currentPosition.nextElement()
}
override fun onTagContentEntity(entity: Entities) {
//logReplace"onTagContentEntity, [${entity.text}]")
check(currentElement != null) {
"No current DOM node"
}
val s = document.createElement("span") as HTMLSpanElement
s.innerHTML = entity.text
currentPosition.replace(
s.childNodes.asList().firstOrNull() ?: document.createTextNode(entity.text)
)
currentPosition.nextElement()
}
override fun onTagContentUnsafe(block: Unsafe.() -> Unit) {
with(DefaultUnsafe()) {
block()
val textContent = toString()
//logReplace"onTagContentUnsafe, [$textContent]")
var namespace: String? = null
if (currentPosition.currentParent().nodeType == 1.toShort()) {
val element = currentPosition.currentParent() as Element
namespace = when (Komponent.unsafeMode) {
UnsafeMode.UNSAFE_ALLOWED -> {
element.namespaceURI
}
UnsafeMode.UNSAFE_SVG_ONLY -> {
if (element.namespaceURI == "http://www.w3.org/2000/svg") {
element.namespaceURI
} else {
null
}
}
else -> {
null
}
}
}
//logReplace"onTagContentUnsafe, namespace: [$namespace]")
if (Komponent.unsafeMode == UnsafeMode.UNSAFE_ALLOWED ||
(
Komponent.unsafeMode == UnsafeMode.UNSAFE_SVG_ONLY &&
namespace == "http://www.w3.org/2000/svg"
)
) {
if (currentElement?.innerHTML != textContent) {
currentElement?.innerHTML += textContent
}
} else if (currentElement?.textContent != textContent) {
currentElement?.textContent = textContent
}
currentPosition.nextElement()
}
}
override fun onTagComment(content: CharSequence) {
//logReplace"onTagComment, [$content]")
check(currentElement != null) {
"No current DOM node"
}
currentElement?.appendChild(
document.createComment(content.toString())
)
currentPosition.nextElement()
}
fun onTagError(tag: Tag, exception: Throwable) {
exceptionThrown = true
if (exception !is KomponentException) {
val position = mutableListOf<Element>()
var ce = currentElement
while (ce != null) {
position.add(ce)
ce = ce.parentElement
}
val builder = StringBuilder()
for (element in position.reversed()) {
builder.append("> ")
builder.append(element.tagName)
builder.append("[")
builder.append(element.findElementIndex())
builder.append("]")
if (element.hasAttribute("class")) {
builder.append("(")
builder.append(element.getAttribute("class"))
builder.append(")")
}
builder.append(" ")
}
throw KomponentException(
komponent,
currentElement,
tag,
builder.toString(),
exception.message ?: "error",
exception
)
} else {
throw exception
}
}
override fun finalize(): Element {
//logReplace"finalize, currentPosition: $currentPosition")
return root ?: throw IllegalStateException(
"We can't finalize as there was no tags"
)
}
companion object {
fun create(content: HtmlBuilder.() -> Unit): Element {
val container = document.createElement("div") as HTMLElement
val consumer = HtmlBuilder(null, container)
content.invoke(consumer)
return consumer.root ?: error("No root element found after render!")
}
}
}

View File

@@ -1,272 +0,0 @@
package nl.astraeus.komp
import kotlinx.browser.window
import kotlinx.html.FlowOrMetaDataOrPhrasingContent
import org.w3c.dom.Element
import org.w3c.dom.HTMLElement
import org.w3c.dom.get
private var currentKomponent: Komponent? = null
fun FlowOrMetaDataOrPhrasingContent.currentKomponent(): Komponent =
currentKomponent ?: error("No current komponent defined! Only call from render code!")
enum class UnsafeMode {
UNSAFE_ALLOWED,
UNSAFE_DISABLED,
UNSAFE_SVG_ONLY
}
var Element.memoizeHash: String?
get() {
return getAttribute("memoize-hash")
}
set(value) {
if (value != null) {
setAttribute("memoize-hash", value.toString())
} else {
removeAttribute("memoize-hash")
}
}
abstract class Komponent {
val createIndex = getNextCreateIndex()
private var dirty: Boolean = true
var element: Element? = null
open fun create(parent: Element, childIndex: Int? = null) {
onBeforeUpdate()
val builder = HtmlBuilder(
this,
parent,
childIndex ?: parent.childNodes.length
)
try {
currentKomponent = this
builder.render()
} catch(e: KomponentException) {
errorHandler(e)
} finally {
currentKomponent = null
}
element = builder.root
updateMemoizeHash()
onAfterUpdate()
}
fun memoizeChanged() = element?.memoizeHash == null || element?.memoizeHash != fullMemoizeHash()
fun updateMemoizeHash() {
element?.memoizeHash = fullMemoizeHash()
}
private fun fullMemoizeHash(): String? {
val generated = generateMemoizeHash()
return if (generated != null) {
"${this::class.simpleName}:${generateMemoizeHash()}"
} else {
null
}
}
abstract fun HtmlBuilder.render()
/**
* This method is called after the Komponent is updated
*
* note: it's also called at first render
*/
open fun onAfterUpdate() {}
/**
* This method is called before the Komponent is updated
* and before memoizeHash is checked
*
* note: it's also called at first render
*/
open fun onBeforeUpdate() {}
fun requestUpdate() {
dirty = true
scheduleForUpdate(this)
}
/**
* Request an immediate update of this Komponent
*
* This will run immediately, make sure Komponents are not rendered multiple times
* Any scheduled updates will be run as well
*/
fun requestImmediateUpdate() {
dirty = true
runUpdateImmediately(this)
}
/**
* This function can be overwritten if you know how to update the Komponent yourself
*
* HTMLBuilder.render() is called 1st time the component is rendered, after that this
* method will be called
*
* @deprecated
*/
@Deprecated(
"Deprecated to avoid confusing with requestUpdate, use renderUpdate instead",
ReplaceWith("renderUpdate"),
level = DeprecationLevel.WARNING
)
protected fun update() {
refresh()
}
/**
* This function can be overwritten if you know how to update the Komponent yourself
*
* HTMLBuilder.render() is called 1st time the component is rendered, after that this
* method will be called
*/
open fun renderUpdate() {
refresh()
}
/**
* If this function returns a value it will be stored and on the next render it will be compared.
*
* The render will only happen if the hash is not null and has changed
*/
open fun generateMemoizeHash(): Int? = null
private fun refresh() {
val currentElement = element
check(currentElement != null) {
error("element is null")
}
val parent = currentElement.parentElement as? HTMLElement ?: error("parent is null!?")
var childIndex = 0
for (index in 0 until parent.childNodes.length) {
if (parent.childNodes[index] == currentElement) {
childIndex = index
}
}
val builder = HtmlBuilder(this, parent, childIndex)
try {
currentKomponent = this
builder.render()
} catch(e: KomponentException) {
errorHandler(e)
} finally {
currentKomponent = null
}
element = builder.root
dirty = false
}
override fun toString(): String {
return "${this::class.simpleName}"
}
companion object {
private var nextCreateIndex: Int = 1
private var updateCallback: Int? = null
private var errorHandler: (KomponentException) -> Unit = { ke ->
println("Render error in Komponent: $ke")
ke.element?.innerHTML = """<div class="komponent-error">Render error!</div>"""
window.alert("""
Error in Komponent '${ke.komponent}', ${ke.message}
Tag: ${ke.tag.tagName}
See console log for details
Position: ${ke.position}""".trimIndent()
)
}
private var scheduledForUpdate = mutableSetOf<Komponent>()
private var interceptor: (Komponent, () -> Unit) -> Unit = { _, block -> block() }
var logRenderEvent = false
var logReplaceEvent = false
var enableAssertions = false
var unsafeMode = UnsafeMode.UNSAFE_DISABLED
fun create(parent: HTMLElement, component: Komponent) {
component.create(parent)
}
fun setErrorHandler(handler: (KomponentException) -> Unit) {
errorHandler = handler
}
fun setUpdateInterceptor(block: (Komponent, () -> Unit) -> Unit) {
interceptor = block
}
private fun getNextCreateIndex() = nextCreateIndex++
private fun scheduleForUpdate(komponent: Komponent) {
scheduledForUpdate.add(komponent)
if (updateCallback == null) {
updateCallback = window.setTimeout({
runUpdate()
}, 0)
}
}
private fun runUpdateImmediately(komponent: Komponent) {
scheduledForUpdate.add(komponent)
runUpdate()
}
private fun runUpdate(): JsAny {
val todo = scheduledForUpdate.sortedBy { komponent -> komponent.createIndex }
if (logRenderEvent) {
println("runUpdate")
}
todo.forEach { next ->
interceptor(next) {
val element = next.element
if (element is HTMLElement) {
if (next.dirty) {
if (logRenderEvent) {
println("Update dirty ${next.createIndex}")
}
val memoizeHash = next.generateMemoizeHash()
if (next.memoizeChanged()) {
next.onBeforeUpdate()
next.renderUpdate()
next.updateMemoizeHash()
next.onAfterUpdate()
} else if (logRenderEvent) {
println("Skipped render, memoizeHash is equal $next-[$memoizeHash]")
}
} else {
if (logRenderEvent) {
println("Skip ${next.createIndex}")
}
}
} else {
println("Komponent element is null $next, $element")
}
}
}
scheduledForUpdate.clear()
updateCallback = null
return "JsAny".toJsString()
}
}
}

View File

@@ -1,18 +0,0 @@
package nl.astraeus.komp
import kotlinx.html.Tag
import org.w3c.dom.Element
class KomponentException(
val komponent: Komponent?,
val element: Element?,
val tag: Tag,
val position: String,
message: String,
cause: Throwable
) : RuntimeException(message, cause) {
override fun toString(): String {
return "KompException(message='$message', tag='$tag', position='$position')"
}
}

View File

@@ -1,54 +0,0 @@
package nl.astraeus.komp
inline fun <reified T> Komponent.mutableCollectionState(
initialValue: MutableCollection<T>
): MutableCollection<T> = MutableCollectionStateDelegate(
this,
initialValue
)
class MutableCollectionStateDelegate<T>(
val komponent: Komponent,
val collection: MutableCollection<T>
): MutableCollection<T> by collection {
override fun add(element: T): Boolean {
komponent.requestUpdate()
return collection.add(element)
}
override fun addAll(elements: Collection<T>): Boolean {
komponent.requestUpdate()
return collection.addAll(elements)
}
override fun clear() {
komponent.requestUpdate()
collection.clear()
}
// todo: return iterator wrapper to update at changes?
//override fun iterator(): MutableIterator<T> = collection.iterator()
override fun remove(element: T): Boolean {
komponent.requestUpdate()
return collection.remove(element)
}
override fun removeAll(elements: Collection<T>): Boolean {
komponent.requestUpdate()
return collection.removeAll(elements)
}
override fun retainAll(elements: Collection<T>): Boolean {
komponent.requestUpdate()
return collection.retainAll(elements)
}
}

View File

@@ -1,56 +0,0 @@
package nl.astraeus.komp
import kotlin.reflect.KProperty
interface Delegate<T> {
operator fun getValue(
thisRef: Any?,
property: KProperty<*>
): T
operator fun setValue(
thisRef: Any?,
property: KProperty<*>,
value: T
)
}
open class StateDelegate<T>(
val komponent: Komponent,
initialValue: T
) : Delegate<T> {
private var value: T = initialValue
init {
if (value is MutableCollection<*>) {
error("Use mutableList to create a collection!")
}
}
override operator fun getValue(
thisRef: Any?,
property: KProperty<*>
): T {
return value
}
override operator fun setValue(
thisRef: Any?,
property: KProperty<*>,
value: T
) {
if (this.value?.equals(value) != true) {
this.value = value
komponent.requestUpdate()
}
}
}
inline fun <reified T> Komponent.state(
initialValue: T
): Delegate<T> = StateDelegate(
this,
initialValue
)