Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Repairs event handling with focus of manipulation and filtering #861

Merged
merged 1 commit into from
May 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
}
}
}
[email protected](name, listener, capture)
[email protected](eventName, listener.unsafeCast<Event.() -> Unit>(), capture)

awaitClose { [email protected](name, listener, capture) }
}.filter { it.asDynamic().fritz2StopPropagation == undefined })
awaitClose { [email protected](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
Loading