Browse Source

web: apply `classes` and `style` before all other attributes and properties (#1788)

* web: apply `classes` before all other attributes and properties

This mitigates "Flash of unstyled content"

* apply style {} before attr(...). Add more tests

* use val instead of var for classes

Co-authored-by: Oleksandr Karpovich <oleksandr.karpovich@jetbrains.com>
pull/1802/head
Oleksandr Karpovich 3 years ago committed by GitHub
parent
commit
af2c354f72
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 18
      web/core/src/jsMain/kotlin/org/jetbrains/compose/web/attributes/AttrsScope.kt
  2. 20
      web/core/src/jsMain/kotlin/org/jetbrains/compose/web/elements/Base.kt
  3. 20
      web/core/src/jsTest/kotlin/InlineStyleTests.kt
  4. 141
      web/core/src/jsTest/kotlin/elements/AttributesTests.kt
  5. 20
      web/svg/src/jsTest/kotlin/svg/SvgTests.kt

18
web/core/src/jsMain/kotlin/org/jetbrains/compose/web/attributes/AttrsScope.kt

@ -47,6 +47,8 @@ interface AttrsScope<TElement : Element>: EventsListenerScope {
* style { maxWidth(5.px) } * style { maxWidth(5.px) }
* }) * })
* ``` * ```
*
* `attr("style", ...)` overrides everything added in `style { }` blocks
*/ */
fun style(builder: StyleScope.() -> Unit) { fun style(builder: StyleScope.() -> Unit) {
styleScope.apply(builder) styleScope.apply(builder)
@ -57,8 +59,10 @@ interface AttrsScope<TElement : Element>: EventsListenerScope {
* This method acts cumulatively, that is, each call adds values to the classList. * This method acts cumulatively, that is, each call adds values to the classList.
* In the ideology of Composable functions and their recomposition one just don't need to remove classes, * In the ideology of Composable functions and their recomposition one just don't need to remove classes,
* since if your classList is, for instance, condition-dependent, you can always just call this method conditionally. * since if your classList is, for instance, condition-dependent, you can always just call this method conditionally.
*
* `attr("class", ...)` overrides everything added using `classes(...)` calls
*/ */
fun classes(vararg classes: String)= prop(setClassList, classes) fun classes(vararg classes: String)
fun id(value: String) = attr(ID, value) fun id(value: String) = attr(ID, value)
fun hidden() = attr(HIDDEN, true.toString()) fun hidden() = attr(HIDDEN, true.toString())
@ -144,6 +148,18 @@ open class AttrsScopeBuilder<TElement : Element> : AttrsScope<TElement>, EventsL
override val propertyUpdates = mutableListOf<Pair<(Element, Any) -> Unit, Any>>() override val propertyUpdates = mutableListOf<Pair<(Element, Any) -> Unit, Any>>()
override var refEffect: (DisposableEffectScope.(TElement) -> DisposableEffectResult)? = null override var refEffect: (DisposableEffectScope.(TElement) -> DisposableEffectResult)? = null
internal val classes: MutableList<String> = mutableListOf()
/**
* [classes] adds all values passed as params to the element's classList.
* This method acts cumulatively, that is, each call adds values to the classList.
* In the ideology of Composable functions and their recomposition one just don't need to remove classes,
* since if your classList is, for instance, condition-dependent, you can always just call this method conditionally.
*/
override fun classes(vararg classes: String) {
this.classes.addAll(classes)
}
/** /**
* [ref] can be used to retrieve a reference to a html element. * [ref] can be used to retrieve a reference to a html element.
* The lambda that `ref` takes in is not Composable. It will be called only once when an element added into a composition. * The lambda that `ref` takes in is not Composable. It will be called only once when an element added into a composition.

20
web/core/src/jsMain/kotlin/org/jetbrains/compose/web/elements/Base.kt

@ -54,8 +54,6 @@ private class DomElementWrapper(override val node: Element): DomNodeWrapper(node
} }
fun updateProperties(applicators: List<Pair<(Element, Any) -> Unit, Any>>) { fun updateProperties(applicators: List<Pair<(Element, Any) -> Unit, Any>>) {
node.removeAttribute("class")
applicators.forEach { (applicator, item) -> applicators.forEach { (applicator, item) ->
applicator(node, item) applicator(node, item)
} }
@ -81,14 +79,25 @@ private class DomElementWrapper(override val node: Element): DomNodeWrapper(node
fun updateAttrs(attrs: Map<String, String>) { fun updateAttrs(attrs: Map<String, String>) {
node.getAttributeNames().forEach { name -> node.getAttributeNames().forEach { name ->
if (name == "style") return@forEach when (name) {
node.removeAttribute(name) "style", "class" -> {
// skip style and class here, they're managed in corresponding methods
}
else -> node.removeAttribute(name)
}
} }
attrs.forEach { attrs.forEach {
node.setAttribute(it.key, it.value) node.setAttribute(it.key, it.value)
} }
} }
fun updateClasses(classes: List<String>) {
node.removeAttribute("class")
if (classes.isNotEmpty()) {
node.classList.add(*classes.toTypedArray())
}
}
} }
@ -115,10 +124,11 @@ fun <TElement : Element> TagElement(
refEffect = attrsScope.refEffect refEffect = attrsScope.refEffect
update { update {
set(attrsScope.classes, DomElementWrapper::updateClasses)
set(attrsScope.styleScope, DomElementWrapper::updateStyleDeclarations)
set(attrsScope.collect(), DomElementWrapper::updateAttrs) set(attrsScope.collect(), DomElementWrapper::updateAttrs)
set(attrsScope.collectListeners(), DomElementWrapper::updateEventListeners) set(attrsScope.collectListeners(), DomElementWrapper::updateEventListeners)
set(attrsScope.propertyUpdates, DomElementWrapper::updateProperties) set(attrsScope.propertyUpdates, DomElementWrapper::updateProperties)
set(attrsScope.styleScope, DomElementWrapper::updateStyleDeclarations)
} }
}, },
elementScope = scope, elementScope = scope,

20
web/core/src/jsTest/kotlin/InlineStyleTests.kt

@ -164,10 +164,12 @@ class InlineStyleTests {
}) {} }) {}
} }
assertEquals( with(nextChild()) {
expected = "<span id=\"container\" style=\"opacity: 0.4; padding: 40px;\"></span>", val attrsMap = getAttributeNames().associateWith { getAttribute(it) }
actual = root.innerHTML assertEquals(2, attrsMap.size)
) assertEquals("container", attrsMap["id"])
assertEquals("opacity: 0.4; padding: 40px;", attrsMap["style"])
}
} }
@Test @Test
@ -181,9 +183,11 @@ class InlineStyleTests {
}) })
} }
assertEquals( with(nextChild()) {
expected = "<span id=\"container\" style=\"height: auto;\"></span>", val attrsMap = getAttributeNames().associateWith { getAttribute(it) }
actual = root.innerHTML assertEquals(2, attrsMap.size)
) assertEquals("container", attrsMap["id"])
assertEquals("height: auto;", attrsMap["style"])
}
} }
} }

141
web/core/src/jsTest/kotlin/elements/AttributesTests.kt

@ -16,9 +16,109 @@ import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import org.jetbrains.compose.web.testutils.* import org.jetbrains.compose.web.testutils.*
import org.w3c.dom.HTMLInputElement import org.w3c.dom.HTMLInputElement
import kotlin.test.assertContains
import kotlin.test.assertTrue
class AttributesTests { class AttributesTests {
@Test
fun correctOrderOfApplyingClasses() = runTest {
composition {
Div(attrs = {
prop<HTMLDivElement, String>({ element, v ->
assertEquals("c1 c2 c3 c4", element.classList.value)
element.classList.add(v)
}, "classFromProperty1")
classes("c1", "c2")
classes("c3", "c4")
}) {
Text("test")
}
}
with(nextChild()) {
assertEquals("c1 c2 c3 c4 classFromProperty1", getAttribute("class"))
}
}
@Test
fun correctOrderOfApplyingClasses2() = runTest {
composition {
Div(attrs = {
// attr rewrites the content of 'class'
attr("class", "classSetFromAttr")
prop<HTMLDivElement, String>({ element, v ->
assertEquals("classSetFromAttr", element.classList.value)
element.classList.add(v)
}, "classFromProperty1")
classes("c1", "c2")
classes("c3", "c4")
}) {
Text("test")
}
}
with(nextChild()) {
assertEquals("classSetFromAttr classFromProperty1", getAttribute("class"))
}
}
@Test
fun attrClassOverridesClassesCall() = runTest {
composition {
Div(attrs = {
// attr rewrites the content of 'class'
attr("class", "classSetFromAttr")
classes("c1")
})
}
with(nextChild()) {
assertEquals("classSetFromAttr", getAttribute("class"))
}
}
@Test
fun attrStyleOverridesStyleCall() = runTest {
composition {
Div(attrs = {
// attr rewrites the content of 'style'
attr("style", "color: red;")
style {
color(Color.green)
}
})
}
with(nextChild()) {
assertEquals("color: red;", getAttribute("style"))
}
}
@Test
fun propCanSeeAllAttrsSet() = runTest {
val attrsCollectedInProp = mutableMapOf<String, String>()
composition {
Div(attrs = {
attr("style", "color: red;")
attr("class", "c1")
prop<HTMLDivElement, Unit>({ e, _ ->
attrsCollectedInProp.putAll(
e.getAttributeNames().associateWith { e.getAttribute(it)!! }
)
}, Unit)
})
}
assertEquals("color: red;", attrsCollectedInProp["style"])
assertEquals("c1", attrsCollectedInProp["class"])
assertEquals(2, attrsCollectedInProp.size)
}
@Test @Test
fun copyFromStyleBuilderCopiesCorrectly() { fun copyFromStyleBuilderCopiesCorrectly() {
val copyFromStyleBuilder = StyleScopeBuilder().apply { val copyFromStyleBuilder = StyleScopeBuilder().apply {
@ -43,17 +143,17 @@ class AttributesTests {
classes("a b c") classes("a b c")
attr("title", "customTitle") attr("title", "customTitle")
prop<HTMLElement, String>({_, _ ->}, "Value") prop<HTMLElement, String>({ _, _ -> }, "Value")
ref { onDispose { } } ref { onDispose { } }
style { style {
width(500.px) width(500.px)
backgroundColor(Color.red) backgroundColor(Color.red)
} }
onClick { } onClick { }
onFocusIn { } onFocusIn { }
onMouseEnter { } onMouseEnter { }
} }
val copyToAttrsScope = AttrsScopeBuilder<HTMLElement>().apply { val copyToAttrsScope = AttrsScopeBuilder<HTMLElement>().apply {
@ -75,7 +175,7 @@ class AttributesTests {
val copyToAttrsScope = AttrsScopeBuilder<HTMLElement>().apply { val copyToAttrsScope = AttrsScopeBuilder<HTMLElement>().apply {
id("id1") id("id1")
onClick { } onClick { }
style { style {
width(100.px) width(100.px)
} }
@ -387,18 +487,31 @@ class AttributesTests {
} }
} }
assertEquals( with(nextChild()) {
expected = "<button class=\"a\" style=\"color: red;\">Button</button>", val attrs = getAttributeNames().toList()
actual = nextChild().outerHTML assertEquals(2, attrs.size)
) assertTrue(attrs.containsAll(listOf("style", "class",)))
assertEquals("button", tagName.lowercase())
assertEquals("a", getAttribute("class"))
assertEquals("color: red;", getAttribute("style"))
assertEquals("Button", innerText)
}
hasValue = true hasValue = true
waitForRecompositionComplete() waitForRecompositionComplete()
assertEquals( with(currentChild()) {
expected = "<button style=\"color: red;\" value=\"buttonValue\" class=\"a b\">Button</button>", val attrs = getAttributeNames().toList()
actual = currentChild().outerHTML assertEquals(3, attrs.size)
) assertTrue(attrs.containsAll(listOf("style", "class", "value")))
assertEquals("button", tagName.lowercase())
assertEquals("a b", getAttribute("class"))
assertEquals("buttonValue", getAttribute("value"))
assertEquals("color: red;", getAttribute("style"))
assertEquals("Button", innerText)
}
} }
@Test @Test

20
web/svg/src/jsTest/kotlin/svg/SvgTests.kt

@ -12,6 +12,8 @@ import org.jetbrains.compose.web.css.px
import org.jetbrains.compose.web.svg.* import org.jetbrains.compose.web.svg.*
import org.jetbrains.compose.web.testutils.* import org.jetbrains.compose.web.testutils.*
import org.w3c.dom.svg.SVGCircleElement import org.w3c.dom.svg.SVGCircleElement
import org.w3c.dom.svg.SVGElement
import org.w3c.dom.svg.SVGTextElement
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@ -132,16 +134,20 @@ class SvgTests {
fun svgTextTest() = runTest { fun svgTextTest() = runTest {
composition { composition {
Svg { Svg {
SvgText("some text", 20, 30, { SvgText("some text", 20, 30) {
classes("small") classes("small")
}) }
} }
} }
assertEquals( with(nextChild<SVGElement>().firstChild!! as SVGTextElement) {
"<svg><text x=\"20\" y=\"30\" class=\"small\">some text</text></svg>", assertEquals("text", this.nodeName.lowercase())
nextChild<SVGCircleElement>().outerHTML assertEquals(3, this.attributes.length)
) assertEquals("small", this.getAttribute("class"))
assertEquals("20", this.getAttribute("x"))
assertEquals("30", this.getAttribute("y"))
assertEquals("some text", this.innerHTML)
}
} }
@Test @Test
@ -415,7 +421,7 @@ class SvgTests {
} }
assertEquals( assertEquals(
"<svg><symbol id=\"myDot\" width=\"10\" height=\"10\" viewBox=\"0 0 2 2\"><circle cx=\"1px\" cy=\"1px\" r=\"1px\"></circle></symbol><use href=\"myDot\" x=\"5\" y=\"5\" style=\"opacity: 1;\"></use></svg>", "<svg><symbol id=\"myDot\" width=\"10\" height=\"10\" viewBox=\"0 0 2 2\"><circle cx=\"1px\" cy=\"1px\" r=\"1px\"></circle></symbol><use style=\"opacity: 1;\" href=\"myDot\" x=\"5\" y=\"5\"></use></svg>",
nextChild<SVGCircleElement>().outerHTML nextChild<SVGCircleElement>().outerHTML
) )
} }

Loading…
Cancel
Save