diff --git a/src/main/scala/com/raquo/laminar/codecs/CompositeCodec.scala b/src/main/scala/com/raquo/laminar/codecs/CompositeCodec.scala index 610f4315..e917c899 100644 --- a/src/main/scala/com/raquo/laminar/codecs/CompositeCodec.scala +++ b/src/main/scala/com/raquo/laminar/codecs/CompositeCodec.scala @@ -1,7 +1,6 @@ package com.raquo.laminar.codecs -import com.raquo.ew.ewArray - +import scala.scalajs.js.| import scala.scalajs.js.JSStringOps._ /** Such codecs are used for space-separated or comma-separated values, e.g. in the `cls` attr. */ @@ -14,6 +13,54 @@ class CompositeCodec(separator: String) extends Codec[Iterable[String], String] override def encode(scalaValue: Iterable[String]): String = { scalaValue.mkString(separator) } + + /** Updates a composite value using this codec's decode and encode methods. + * + * @param domValue raw DOM value, `()` if the attribute is not set + * @param addItems must be normalized + */ + def encodeUpdated( + domValue: String | Unit, + removeItems: List[String], + addItems: List[String] + ): String = { + encode(domValue.map(decode).getOrElse(Nil).filterNot(removeItems.contains) ++ addItems) + } +} + +final private[laminar] class DefaultCompositeCodec(separator: String) extends CompositeCodec(separator) { + + override def encodeUpdated( + domValue: String | Unit, + removeItems: List[String], + addItems: List[String] + ): String = { + var result = "" + var isFirst = true + val append = (item: String) => { + if (isFirst) { + result = item + isFirst = false + } else { + result = result + separator + item + } + } + domValue.foreach { value => + if (value.nonEmpty) { + val items = value.jsSplit(separator) + var i = 0 + while (i < items.length) { + val item = items(i) + if (item.nonEmpty && !removeItems.contains(item)) { + append(item) + } + i += 1 + } + } + } + addItems.foreach(append) + result + } } object CompositeCodec { @@ -29,8 +76,22 @@ object CompositeCodec { def normalize(items: String, separator: String): List[String] = { if (items.isEmpty) { Nil + } else if (items.indexOf(separator) == -1) { + // Fast path for the most common case: a single item, e.g. `cls := "active"` + items :: Nil } else { - items.jsSplit(separator).ew.filter(_.nonEmpty).asScalaJs.toList + val splitItems = items.jsSplit(separator) + // Iterate backwards to build the List by prepending, preserving order + var result: List[String] = Nil + var i = splitItems.length - 1 + while (i >= 0) { + val item = splitItems(i) + if (item.nonEmpty) { + result = item :: result + } + i -= 1 + } + result } } } diff --git a/src/main/scala/com/raquo/laminar/keys/CompositeKey.scala b/src/main/scala/com/raquo/laminar/keys/CompositeKey.scala index b700debb..70d3394e 100644 --- a/src/main/scala/com/raquo/laminar/keys/CompositeKey.scala +++ b/src/main/scala/com/raquo/laminar/keys/CompositeKey.scala @@ -2,7 +2,7 @@ package com.raquo.laminar.keys import com.raquo.airstream.core.Source import com.raquo.laminar.api.{MapValueMapper, StringOptionValueMapper, StringSeqValueMapper, StringValueMapper} -import com.raquo.laminar.codecs.CompositeCodec +import com.raquo.laminar.codecs.{CompositeCodec, DefaultCompositeCodec} import com.raquo.laminar.modifiers.{CompositeKeySetter, CompositeKeyUpdater} import com.raquo.laminar.nodes.ReactiveElement @@ -32,7 +32,7 @@ abstract class CompositeKey[ // val separator: String - val codec: CompositeCodec = new CompositeCodec(separator) + val codec: CompositeCodec = new DefaultCompositeCodec(separator) def :=(items: String): CompositeKeySetter[Self, El] = { addStaticItems(StringValueMapper.toNormalizedList(items, separator)) diff --git a/src/main/scala/com/raquo/laminar/nodes/ReactiveElement.scala b/src/main/scala/com/raquo/laminar/nodes/ReactiveElement.scala index 78d718a7..4fa477f2 100644 --- a/src/main/scala/com/raquo/laminar/nodes/ReactiveElement.scala +++ b/src/main/scala/com/raquo/laminar/nodes/ReactiveElement.scala @@ -127,13 +127,14 @@ with ParentNode[Ref] { val itemsToAdd = addItems.distinct val itemsToRemoveFromDom = removeItems.filterNot(itemHasAnotherReason) - val newItems = _compositeValues - .getOrElse(key, Nil) + val newItems = keyItemsWithReason .filterNot(t => removeItems.contains(t._1) && t._2 == reason) ++ itemsToAdd.map((_, reason)) - val domValues = key.getRawDomValue(this).map(key.codec.decode).getOrElse(Nil) - - val nextDomValues = domValues.filterNot(itemsToRemoveFromDom.contains) ++ itemsToAdd.filterNot(itemHasAnotherReason) + val nextDomValue = key.codec.encodeUpdated( + domValue = key.getRawDomValue(this), + removeItems = itemsToRemoveFromDom, + addItems = itemsToAdd.filterNot(itemHasAnotherReason) + ) // 1. Update Laminar's internal structure _compositeValues = _compositeValues.updated(key, newItems) @@ -142,7 +143,7 @@ with ParentNode[Ref] { // #Note this logic is compatible with third parties setting classes on Laminar elements // using raw JS methods as long as they don't remove classes managed by Laminar or add // classes that were also added by Laminar. - key.setRawDomValue(this, key.codec.encode(nextDomValues)) + key.setRawDomValue(this, nextDomValue) } val tag: Tag[ReactiveElement[Ref]] diff --git a/src/test/scala/com/raquo/laminar/tests/CompositeKeySpec.scala b/src/test/scala/com/raquo/laminar/tests/CompositeKeySpec.scala index 17ce1a4b..d6cb1c4f 100644 --- a/src/test/scala/com/raquo/laminar/tests/CompositeKeySpec.scala +++ b/src/test/scala/com/raquo/laminar/tests/CompositeKeySpec.scala @@ -1,10 +1,39 @@ package com.raquo.laminar.tests import com.raquo.laminar.api.L._ +import com.raquo.laminar.codecs.CompositeCodec +import com.raquo.laminar.keys.CompositeAttr +import com.raquo.laminar.nodes.ReactiveElement import com.raquo.laminar.utils.UnitSpec class CompositeKeySpec extends UnitSpec { + it("custom composite codec uses its encode and decode overrides") { + val customKey = new CompositeAttr[ReactiveElement.Base]("data-custom", " ") { + override val codec: CompositeCodec = new CompositeCodec(" ") { + override def decode(domValue: String): List[String] = { + super.decode(domValue.stripPrefix("(").stripSuffix(")")) + } + + override def encode(scalaValue: Iterable[String]): String = { + "(" + super.encode(scalaValue) + ")" + } + } + } + val item = Var("bar") + val el = div(customKey := "foo", customKey <-- item) + mount(el) + + withClue("Initial binding:") { + assert(el.ref.getAttribute("data-custom") == "(foo bar)") + } + + withClue("Reactive update:") { + item.set("baz") + assert(el.ref.getAttribute("data-custom") == "(foo baz)") + } + } + it("cls - simple static modifiers") { val el = div(className := "foo") mount(el)