Skip to content

Commit

Permalink
Repairs event handling with focus of manipulation and filtering
Browse files Browse the repository at this point in the history
- enables filtering and manipulating events immediately after occurrence due to specialized event factories. Prevent `Flow` based delay we had until now.
- simplifies `Listener`-type. It remains more or less as a marker type, in order to dispatch the convenience functions to grab values out of specific DOM elements.
- get rid of unnecessary convenience functions
- remove `@ignore` from one test case as the cause is now fixed
- add a new dedicated documentation chapter for event handling including `Key`-API
  • Loading branch information
christian.hausknecht committed May 2, 2024
1 parent b91675e commit 0b78b92
Show file tree
Hide file tree
Showing 15 changed files with 4,176 additions and 477 deletions.
3,902 changes: 3,580 additions & 322 deletions core/src/jsMain/kotlin/dev/fritz2/core/events.kt

Large diffs are not rendered by default.

112 changes: 34 additions & 78 deletions core/src/jsMain/kotlin/dev/fritz2/core/listener.kt
Original file line number Diff line number Diff line change
@@ -1,100 +1,56 @@
@file:Suppress("unused")

package dev.fritz2.core

import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import org.w3c.dom.*
import org.w3c.dom.events.Event
import org.w3c.dom.events.EventTarget
import org.w3c.files.FileList

/**
* Creates a [Listener] for the given [Event] type and [name].
* Creates a [Listener] for the given [Event] type and [eventName].
*
* @param eventName the [DOM-API name](https://developer.mozilla.org/en-US/docs/Web/API/Element#events) of an event.
* Can be a custom name.
* @param capture if `true`, activates capturing mode, else remains in `bubble` mode (default)
* @param selector optional lambda expression to select specific events with option to manipulate it
* (e.g. `preventDefault` or `stopPropagation`).
*
* @return a [Listener]-object, which is more or less a [Flow] of the specific `Event`-type.
*/
fun <X : Event, T : EventTarget> T.subscribe(
name: String,
fun <E : Event, T : EventTarget> T.subscribe(
eventName: String,
capture: Boolean = false,
init: Event.() -> Unit = {}
): Listener<X, T> =
Listener(callbackFlow {
val listener: (Event) -> Unit = {
try {
it.init()
trySend(it.unsafeCast<X>())
} catch (e: Exception) {
console.error("Unexpected event type while listening for `$name` event", e)
selector: E.() -> Boolean = { true }
): Listener<E, T> =
Listener(
callbackFlow {
val listener: (E) -> Unit = {
try {
if (it.selector()) trySend(it.unsafeCast<E>())
} catch (e: Exception) {
console.error("Unexpected event type while listening for `$eventName` event", e)
}
}
}
this@subscribe.addEventListener(name, listener, capture)
this@subscribe.addEventListener(eventName, listener.unsafeCast<Event.() -> Unit>(), capture)

awaitClose { this@subscribe.removeEventListener(name, listener, capture) }
}.filter { it.asDynamic().fritz2StopPropagation == undefined })
awaitClose { this@subscribe.removeEventListener(eventName, listener.unsafeCast<Event.() -> Unit>(), capture) }
}
)

/**
* Encapsulates the [Flow] of the [Event].
*
* Acts as a marker class in order to keep the type of the element, so we can offer dedicated methods to extract
* values from some specific events.
*
* @see [values]
*/
class Listener<X: Event, out T: EventTarget>(private val events: Flow<X>): Flow<X> by events {

constructor(listener: Listener<X, T>) : this(listener.events)

/**
* Calls [Event.preventDefault] on the [Event]-flow.
*/
fun preventDefault(): Listener<X, T> = Listener(this.events.map { it.preventDefault(); it })

/**
* Calls [Event.stopImmediatePropagation] on the [Event]-flow.
*/
fun stopImmediatePropagation(): Listener<X, T> = Listener(this.events.map {
it.stopImmediatePropagation()
it.asDynamic().fritz2StopPropagation = true
it
})

/**
* Calls [Event.stopPropagation] on the [Event]-flow.
*/
fun stopPropagation(): Listener<X, T> = Listener(this.events.map {
it.stopPropagation()
it.asDynamic().fritz2StopPropagation = true
it
})

/**
* Calls [Event.composedPath] on the [Event]-flow.
*/
fun composedPath(): Flow<Array<EventTarget>> = this.events.map { it.composedPath() }

}

/**
* Calls [Event.preventDefault] on the [Event]-flow.
*/
fun <E: Event> Flow<E>.preventDefault(): Flow<E> = this.map { it.preventDefault(); it }
/**
* Calls [Event.stopImmediatePropagation] on the [Event]-flow.
*/
fun <E: Event> Flow<E>.stopImmediatePropagation(): Flow<E> = this.map {
it.stopImmediatePropagation()
it.asDynamic().fritz2StopPropagation = true
it
}
/**
* Calls [Event.stopPropagation] on the [Event]-flow.
*/
fun <E: Event> Flow<E>.stopPropagation(): Flow<E> = this.map {
it.stopPropagation()
it.asDynamic().fritz2StopPropagation = true
it
}
/**
* Calls [Event.composedPath] on the [Event]-flow.
*/
fun <E: Event> Flow<E>.composedPath(): Flow<Array<EventTarget>> = this.map { it.composedPath() }

value class Listener<X : Event, out T : EventTarget>(private val events: Flow<X>) : Flow<X> by events

/**
* Extracts the [HTMLInputElement.value] from the [Event.target].
Expand All @@ -103,7 +59,7 @@ fun Listener<*, HTMLInputElement>.values(): Flow<String> =
this.map { it.target.unsafeCast<HTMLInputElement>().value }

/**
* Extracts the [HTMLInputElement.value] from the [Event.target].
* Extracts the [HTMLSelectElement.value] from the [Event.target].
*/
fun Listener<*, HTMLSelectElement>.values(): Flow<String> =
this.map { it.target.unsafeCast<HTMLSelectElement>().value }
Expand All @@ -115,7 +71,7 @@ fun Listener<*, HTMLFieldSetElement>.values(): Flow<String> =
this.map { it.target.unsafeCast<HTMLInputElement>().value }

/**
* Extracts the [HTMLInputElement.value] from the [Event.target].
* Extracts the [HTMLTextAreaElement.value] from the [Event.target].
*/
fun Listener<*, HTMLTextAreaElement>.values(): Flow<String> =
this.map { it.target.unsafeCast<HTMLTextAreaElement>().value }
Expand Down
9 changes: 2 additions & 7 deletions core/src/jsMain/kotlin/dev/fritz2/core/tags.kt
Original file line number Diff line number Diff line change
Expand Up @@ -238,13 +238,8 @@ interface Tag<out E : Element> : RenderContext, WithDomNode<E>, WithEvents<E> {
}
}

/**
* Creates an [Listener] for the given event [eventName].
*
* @param eventName of the [Event] to listen for
*/
override fun <X : Event> subscribe(eventName: String, capture: Boolean, init: Event.() -> Unit): Listener<X, E> =
Listener(domNode.subscribe(eventName, capture, init))
override fun <X : Event> subscribe(eventName: String, capture: Boolean, selector: X.() -> Boolean): Listener<X, E> =
Listener(domNode.subscribe(eventName, capture, selector))

/**
* Adds text-content of a [Flow] at this position
Expand Down
83 changes: 50 additions & 33 deletions core/src/jsTest/kotlin/dev/fritz2/core/events.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class EventsTest {
section {
input(id = inputId) {
value(store.data)
changes.preventDefault().values() handledBy store.update
changes { preventDefault() }.values() handledBy store.update
inputs.values() handledBy store.update
}
}
Expand Down Expand Up @@ -69,10 +69,10 @@ class EventsTest {
render {
section {
div(id = resultId) {
store.data.renderText()
store.data.renderText(into = this)
}
button(id = buttonId) {
clicks.preventDefault() handledBy store.addADot
clicks { preventDefault() } handledBy store.addADot
}
}
}
Expand Down Expand Up @@ -317,7 +317,7 @@ class EventsTest {

val pathSize = storeOf(0)
val setSize = pathSize.handle<Int> { _, size ->
console.log("Store: $size\n");
console.log("Store: $size\n")
size
}

Expand Down Expand Up @@ -356,7 +356,6 @@ class EventsTest {
assertEquals(1, wrapperDiv.getAttribute("path-size")?.toInt())
}

@Ignore
@Test
fun testWindowListenerForStopImmediatePropagation() = runTest {

Expand All @@ -369,7 +368,7 @@ class EventsTest {
val windowStore = object : RootStore<String>("", job = Job()) {}

render {
Window.clicks.stopImmediatePropagation().map {
Window.clicks { stopImmediatePropagation() }.map {
windowEventText
} handledBy windowStore.update

Expand Down Expand Up @@ -428,32 +427,25 @@ class EventsTest {

@Test
fun testEventCapturedStopPropagation() = runTest {

val outerId = Id.next()
val innerId = Id.next()

val store = storeOf("")
val concat = store.handle<String> { self, input -> self + input }
var result = ""

render {
div(id = outerId) {
attr("data-value", store.data)
clicksCaptured.stopPropagation().map { "o" } handledBy concat
div {
clicksCaptured { stopPropagation() } handledBy { result += "outer" }
clicks handledBy { result += "outer" }

div(id = innerId) {
clicks.stopPropagation().map { "i" } handledBy concat
clicks handledBy { result += "inner" }
}
}
}

delay(100)
val outerDiv = document.getElementById(outerId).unsafeCast<HTMLDivElement>()
assertEquals("", outerDiv.getAttribute("data-value"))

val innerDiv = document.getElementById(innerId).unsafeCast<HTMLDivElement>()
innerDiv.click()
delay(100)
assertEquals("o", outerDiv.getAttribute("data-value"))
assertEquals("outer", result)
}

@Test
Expand Down Expand Up @@ -488,31 +480,56 @@ class EventsTest {

@Test
fun testEventBubbledStopPropagation() = runTest {

val outerId = Id.next()
val innerId = Id.next()

val store = storeOf("")
val concat = store.handle<String> { self, input -> self + input }
var result = ""

render {
div(id = outerId) {
attr("data-value", store.data)
clicks.stopPropagation().map { "o" } handledBy concat
div {
+"outer div"

div(id = innerId) {
clicks.stopPropagation().map { "i" } handledBy concat
clicks handledBy { result += "outer" }

button(id = innerId) {
+"Button"
type("button")

clicks { stopPropagation() } handledBy { result += "in" }
clicks handledBy { result += "side" }
}
}
}

delay(100)
val outerDiv = document.getElementById(outerId).unsafeCast<HTMLDivElement>()
assertEquals("", outerDiv.getAttribute("data-value"))

val innerDiv = document.getElementById(innerId).unsafeCast<HTMLDivElement>()
innerDiv.click()
delay(100)
assertEquals("i", outerDiv.getAttribute("data-value"))
assertEquals("inside", result)
}

@Test
fun testEventFactoryWithFilteringSelectorDropsUnwantedEvents() = runTest {
val tagId = Id.next()
var result = ""

val keysToProcess = "fritz2".map { shortcutOf(it.toString()) }.toSet()
val keyPool: List<String> = (('a'..'z') + ('0'..'9')).map(Char::toString)
// swap `i` with`r` to get the right sequence of keys pressed for our framework name ;-)
.map { if(it == "i") "r" else if(it == "r") "i" else it}

render {
div(id = tagId) {
keydownsIf { shortcutOf(this) in keysToProcess } handledBy { result += it.key }
}
}

delay(100)
val innerDiv = document.getElementById(tagId).unsafeCast<HTMLDivElement>()

keyPool.forEach { key ->
innerDiv.dispatchEvent(KeyboardEvent("keydown", KeyboardEventInit(key = key)))
delay(50)
}

assertEquals("fritz2", result)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package dev.fritz2.headless.components

import dev.fritz2.core.*
import dev.fritz2.headless.foundation.*
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import org.w3c.dom.*
Expand Down Expand Up @@ -166,11 +165,15 @@ class CheckboxGroup<C : HTMLElement, T>(tag: Tag<C>, private val explicitId: Str
if (withKeyboardNavigation) {
value.handler?.invoke(
this,
keydowns.filter { shortcutOf(it) == Keys.Space }
.stopImmediatePropagation().preventDefault()
.map {
val value = value.data.first()
if (value.contains(option)) value - option else value + option
keydownsIf {
if (shortcutOf(this) == Keys.Space) {
preventDefault()
stopImmediatePropagation()
true
} else false
}.map {
val value = value.data.first()
if (value.contains(option)) value - option else value + option
})
}
}.also { toggle = it }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import org.w3c.dom.HTMLElement
/**
* This class provides the building blocks to implement a disclosure.
*
* Use [disclosure] functions to create an instance, set up the needed [Hook]s or [Property]s and refine the
* Use [disclosure] functions to create an instance, set up the needed `Hook`s or `Property`s, and refine the
* component by using the further factory methods offered by this class.
*
* For more information refer to the [official documentation](https://www.fritz2.dev/headless/disclosure)
Expand Down Expand Up @@ -49,7 +49,7 @@ class Disclosure<C : HTMLElement>(tag: Tag<C>, id: String?) : Tag<C> by tag, Ope
content()
attr(Aria.expanded, opened.asString())
attr("tabindex", "0")
activations.preventDefault().stopPropagation() handledBy toggle
activations { preventDefault(); stopPropagation() } handledBy toggle
}.also { button = it }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class Listbox<T, C : HTMLElement>(tag: Tag<C>, id: String?) : Tag<C> by tag, Ope
if (!openState.isSet) openState(storeOf(false))
content()
attr(Aria.expanded, opened.asString())
activations.preventDefault().stopPropagation() handledBy toggle
activations { preventDefault(); stopPropagation() } handledBy toggle
}.also { button = it }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ class Menu<C : HTMLElement>(tag: Tag<C>, id: String?) : Tag<C> by tag, OpenClose
if (!openState.isSet) openState(storeOf(false))
content()
attr(Aria.expanded, opened.asString())
activations.preventDefault().stopPropagation() handledBy toggle
activations { preventDefault(); stopPropagation() } handledBy toggle
}.also { button = it }
}

Expand Down Expand Up @@ -101,7 +101,7 @@ class Menu<C : HTMLElement>(tag: Tag<C>, id: String?) : Tag<C> by tag, OpenClose
"$componentId-items",
scope,
[email protected],
reference = button ?: button { },
reference = button ?: button { },
ariaHasPopup = Aria.HasPopup.menu
) {

Expand Down
Loading

0 comments on commit 0b78b92

Please sign in to comment.